Skip to main content

datui_lib/cloud/
azure.rs

1//! Azure Blob Storage: logins, finding storage accounts, and listing what is in them.
2//!
3//! A signed-in `az` is asked for tokens, as object_store itself does, rather than its
4//! token cache being read: the cache is an internal format, encrypted to the user on
5//! Windows, and asking keeps MFA and conditional access working without datui knowing
6//! about either. One Resource Graph query finds every storage account the login can
7//! see across its subscriptions, so nobody has to name an account to browse it.
8//!
9//! Everything here that touches the network or runs `az` blocks, and is only called
10//! from a worker.
11
12use crate::cloud::cloud_browse::Environment;
13use crate::cloud::cloud_command::CommandError;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Mutex, OnceLock};
17use std::time::{Duration, SystemTime};
18
19/// The scope of a token for reading blobs.
20pub const STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
21/// The scope of a token for Resource Graph, which finds storage accounts.
22pub const MANAGEMENT_SCOPE: &str = "https://management.azure.com/.default";
23/// Sent on every request. Without it, anonymous requests are refused with
24/// `FeatureVersionMismatch`, and newer response fields are left out.
25pub const API_VERSION: &str = "2023-11-03";
26
27/// How a request to one account is authorized.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum AzureAuth {
30    /// Nothing known yet.
31    #[default]
32    None,
33    /// A signed-in `az`, asked for a token when one is needed. When `az` is not signed
34    /// in and Azure PowerShell is, PowerShell is asked instead.
35    AzCli,
36    /// A signed-in Azure PowerShell (`Connect-AzAccount`).
37    PowerShell,
38    /// A service principal or workload identity from `AZURE_CLIENT_ID` and friends.
39    ServicePrincipal(ServicePrincipal),
40    /// The managed identity of the VM, App Service or Function datui runs on.
41    ManagedIdentity,
42    /// The account key, printed by a `secret_command`.
43    KeyCommand(String),
44    /// An Entra ID token.
45    Bearer(String),
46    /// The account's shared key.
47    Key(String),
48    /// A shared access signature, without the leading `?`.
49    Sas(String),
50}
51
52/// An application's identity in Entra ID: a client secret, or a federated token file
53/// (AKS workload identity) exchanged for a token each time.
54#[derive(Clone, PartialEq, Eq, Default)]
55pub struct ServicePrincipal {
56    pub tenant: String,
57    pub client_id: String,
58    pub secret: Option<String>,
59    pub token_file: Option<PathBuf>,
60    /// `AZURE_AUTHORITY_HOST`, for sovereign clouds.
61    pub authority: String,
62}
63
64impl std::fmt::Debug for ServicePrincipal {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("ServicePrincipal")
67            .field("tenant", &self.tenant)
68            .field("client_id", &self.client_id)
69            .field("secret", &self.secret.as_ref().map(|_| "…"))
70            .field("token_file", &self.token_file)
71            .finish()
72    }
73}
74
75impl AzureAuth {
76    /// A login that is asked for tokens: it may reach any account it can see.
77    pub fn is_identity(&self) -> bool {
78        matches!(
79            self,
80            AzureAuth::AzCli
81                | AzureAuth::PowerShell
82                | AzureAuth::ServicePrincipal(_)
83                | AzureAuth::ManagedIdentity
84        )
85    }
86
87    /// Where the login is from, in a word or two.
88    pub fn describe(&self) -> &'static str {
89        match self {
90            AzureAuth::None => "none",
91            AzureAuth::AzCli => "az login",
92            AzureAuth::PowerShell => "Azure PowerShell",
93            AzureAuth::ServicePrincipal(sp) if sp.token_file.is_some() => "workload identity",
94            AzureAuth::ServicePrincipal(_) => "service principal",
95            AzureAuth::ManagedIdentity => "managed identity",
96            AzureAuth::KeyCommand(_) => "secret_command",
97            AzureAuth::Bearer(_) => "token",
98            AzureAuth::Key(_) => "access key",
99            AzureAuth::Sas(_) => "SAS token",
100        }
101    }
102}
103
104/// How to reach Azure Blob Storage for one source.
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub struct AzureSettings {
107    /// The one account a key, SAS or connection string names. A signed-in identity
108    /// reaches every account it can see, so it has none.
109    pub account: Option<String>,
110    pub auth: AzureAuth,
111    /// A blob endpoint other than `https://<account>.blob.core.windows.net/`.
112    pub blob_endpoint: Option<String>,
113    /// Azurite, the local emulator.
114    pub use_emulator: bool,
115    /// The login a token in `auth` came from, kept for what needs a token of another
116    /// scope: finding accounts, fetching an account's keys.
117    pub identity: Option<AzureAuth>,
118}
119
120impl AzureSettings {
121    /// The blob service endpoint for `account`, ending in `/`.
122    pub fn blob_endpoint_for(&self, account: &str) -> String {
123        match (&self.blob_endpoint, self.use_emulator) {
124            (Some(endpoint), _) => format!("{}/", endpoint.trim_end_matches('/')),
125            (None, true) => format!("http://127.0.0.1:10000/{account}/"),
126            (None, false) => format!("https://{account}.blob.core.windows.net/"),
127        }
128    }
129
130    /// These settings with a token in place of an identity. Runs `az` or PowerShell, or
131    /// asks Entra ID.
132    pub fn with_token(mut self, env: &Environment<'_>) -> Result<Self, String> {
133        if let AzureAuth::KeyCommand(command) = &self.auth {
134            self.auth = AzureAuth::Key(crate::cloud::cloud_command::secret(command, env)?);
135        }
136        if self.auth.is_identity() {
137            let token = identity_token(&self.auth, STORAGE_SCOPE, env)?;
138            self.identity = Some(std::mem::replace(&mut self.auth, AzureAuth::Bearer(token)));
139        }
140        Ok(self)
141    }
142}
143
144/// The Azurite account and key everyone uses; they are published in its documentation.
145const AZURITE_ACCOUNT: &str = "devstoreaccount1";
146const AZURITE_KEY: &str =
147    "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
148
149/// The settings a connection string describes: `AccountName`, `AccountKey`,
150/// `SharedAccessSignature`, `BlobEndpoint`, `EndpointSuffix`,
151/// `DefaultEndpointsProtocol`, or `UseDevelopmentStorage=true` for Azurite.
152pub fn parse_connection_string(text: &str) -> Option<AzureSettings> {
153    let pairs: HashMap<String, String> = text
154        .split(';')
155        .filter_map(|pair| pair.split_once('='))
156        .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))
157        .collect();
158    if pairs
159        .get("usedevelopmentstorage")
160        .is_some_and(|v| v.eq_ignore_ascii_case("true"))
161    {
162        return Some(AzureSettings {
163            account: Some(AZURITE_ACCOUNT.to_string()),
164            auth: AzureAuth::Key(AZURITE_KEY.to_string()),
165            blob_endpoint: None,
166            use_emulator: true,
167            identity: None,
168        });
169    }
170    let account = pairs.get("accountname").cloned();
171    let auth = match pairs.get("accountkey") {
172        Some(key) => AzureAuth::Key(key.clone()),
173        None => AzureAuth::Sas(
174            pairs
175                .get("sharedaccesssignature")?
176                .trim_start_matches('?')
177                .to_string(),
178        ),
179    };
180    let blob_endpoint = pairs.get("blobendpoint").cloned().or_else(|| {
181        let account = account.as_ref()?;
182        let suffix = pairs.get("endpointsuffix")?;
183        let protocol = pairs
184            .get("defaultendpointsprotocol")
185            .map(String::as_str)
186            .unwrap_or("https");
187        Some(format!("{protocol}://{account}.blob.{suffix}"))
188    });
189    // An account can be left out when the blob endpoint names it.
190    let account = account.or_else(|| {
191        let endpoint = blob_endpoint.as_deref()?;
192        let host = endpoint.split_once("://")?.1.split('/').next()?;
193        host.split_once(".blob.").map(|(a, _)| a.to_string())
194    })?;
195    Some(AzureSettings {
196        account: Some(account),
197        auth,
198        blob_endpoint,
199        use_emulator: false,
200        identity: None,
201    })
202}
203
204/// What the environment says about Azure, if anything: a connection string, an account
205/// with a key or SAS token, or a service principal (with an account, or to find them).
206pub fn from_environment(var: &dyn Fn(&str) -> Option<String>) -> Option<(AzureSettings, String)> {
207    let set = |key: &str| {
208        var(key)
209            .map(|v| v.trim().to_string())
210            .filter(|v| !v.is_empty())
211    };
212    if let Some(text) = set("AZURE_STORAGE_CONNECTION_STRING") {
213        return parse_connection_string(&text)
214            .map(|s| (s, "AZURE_STORAGE_CONNECTION_STRING".to_string()));
215    }
216    let account = set("AZURE_STORAGE_ACCOUNT_NAME");
217    let key = set("AZURE_STORAGE_ACCOUNT_KEY").or_else(|| set("AZURE_STORAGE_ACCESS_KEY"));
218    let sas = set("AZURE_STORAGE_SAS_TOKEN").or_else(|| set("AZURE_STORAGE_SAS_KEY"));
219    let (auth, origin) = match (&account, key, sas, service_principal(var)) {
220        (Some(_), Some(key), _, _) => (AzureAuth::Key(key), "AZURE_STORAGE_ACCOUNT_KEY"),
221        (Some(_), None, Some(sas), _) => (
222            AzureAuth::Sas(sas.trim_start_matches('?').to_string()),
223            "AZURE_STORAGE_SAS_TOKEN",
224        ),
225        (_, _, _, Some(sp)) => {
226            let origin = if sp.token_file.is_some() {
227                "AZURE_FEDERATED_TOKEN_FILE"
228            } else {
229                "AZURE_CLIENT_SECRET"
230            };
231            (AzureAuth::ServicePrincipal(sp), origin)
232        }
233        _ => return None,
234    };
235    Some((
236        AzureSettings {
237            account,
238            auth,
239            blob_endpoint: None,
240            use_emulator: false,
241            identity: None,
242        },
243        origin.to_string(),
244    ))
245}
246
247/// A service principal from `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and either
248/// `AZURE_CLIENT_SECRET` or `AZURE_FEDERATED_TOKEN_FILE`, the variables the Azure SDKs
249/// and AKS workload identity set.
250pub fn service_principal(var: &dyn Fn(&str) -> Option<String>) -> Option<ServicePrincipal> {
251    let set = |key: &str| {
252        var(key)
253            .map(|v| v.trim().to_string())
254            .filter(|v| !v.is_empty())
255    };
256    let tenant = set("AZURE_TENANT_ID")?;
257    let client_id = set("AZURE_CLIENT_ID")?;
258    let secret = set("AZURE_CLIENT_SECRET");
259    let token_file = set("AZURE_FEDERATED_TOKEN_FILE").map(PathBuf::from);
260    if secret.is_none() && token_file.is_none() {
261        return None;
262    }
263    Some(ServicePrincipal {
264        tenant,
265        client_id,
266        secret,
267        token_file,
268        authority: set("AZURE_AUTHORITY_HOST")
269            .unwrap_or_else(|| "https://login.microsoftonline.com".to_string()),
270    })
271}
272
273/// Whether `az` has been used on this machine: its config directory exists. Not proof the
274/// login is still good, which only asking can tell.
275pub fn az_login_evidence(env: &Environment<'_>) -> bool {
276    let dir = match (env.var)("AZURE_CONFIG_DIR").filter(|v| !v.trim().is_empty()) {
277        Some(dir) => PathBuf::from(dir.trim()),
278        None => match &env.home {
279            Some(home) => home.join(".azure"),
280            None => return false,
281        },
282    };
283    (env.exists)(&dir)
284}
285
286/// Whether Azure PowerShell has been signed in on this machine: its context file exists.
287pub fn powershell_login_evidence(env: &Environment<'_>) -> bool {
288    powershell_context(env).is_some_and(|path| (env.exists)(&path))
289}
290
291fn powershell_context(env: &Environment<'_>) -> Option<PathBuf> {
292    // `~/.Azure` on every platform; on Windows it is the same directory as `az`'s
293    // `.azure`.
294    Some(
295        env.home
296            .as_ref()?
297            .join(".Azure")
298            .join("AzureRmContext.json"),
299    )
300}
301
302/// Azure tooling on this machine with no sign-in to show for it: `az` on `PATH`, or the
303/// Az.Accounts PowerShell module installed. The fix, naming what is there, when so.
304pub fn not_signed_in(env: &Environment<'_>) -> Option<String> {
305    if az_login_evidence(env) || powershell_login_evidence(env) {
306        return None;
307    }
308    let on_path = |names: &[&str]| {
309        (env.var)("PATH").is_some_and(|path| {
310            std::env::split_paths(&path)
311                .any(|dir| names.iter().any(|name| (env.exists)(&dir.join(name))))
312        })
313    };
314    let az = if env.windows {
315        on_path(&["az.cmd", "az.exe"])
316    } else {
317        on_path(&["az"])
318    };
319    let az_accounts = (env.var)("PSModulePath").is_some_and(|paths| {
320        std::env::split_paths(&paths).any(|dir| (env.exists)(&dir.join("Az.Accounts")))
321    });
322    match (az, az_accounts) {
323        (false, false) => None,
324        (true, false) => Some("not signed in: run az login".to_string()),
325        (false, true) => Some("not signed in: run Connect-AzAccount in PowerShell".to_string()),
326        (true, true) => {
327            Some("not signed in: run az login, or Connect-AzAccount in PowerShell".to_string())
328        }
329    }
330}
331
332/// A token for `scope` from an identity: `az`, Azure PowerShell or a service principal.
333pub fn identity_token(
334    auth: &AzureAuth,
335    scope: &str,
336    env: &Environment<'_>,
337) -> Result<String, String> {
338    match auth {
339        AzureAuth::AzCli => match token(scope, env) {
340            Ok(token) => Ok(token),
341            // `az` is preferred, since it starts faster; PowerShell is asked when it is
342            // the one signed in.
343            Err(az) if powershell_login_evidence(env) => {
344                powershell_token(scope, env).map_err(|ps| format!("{az}; {ps}"))
345            }
346            Err(e) => Err(e),
347        },
348        AzureAuth::PowerShell => powershell_token(scope, env),
349        AzureAuth::ServicePrincipal(sp) => service_principal_token(sp, scope, env),
350        AzureAuth::ManagedIdentity => managed_identity_token(scope, env),
351        AzureAuth::Bearer(token) => Ok(token.clone()),
352        AzureAuth::None | AzureAuth::Key(_) | AzureAuth::Sas(_) | AzureAuth::KeyCommand(_) => {
353            Err("this login has no tokens".to_string())
354        }
355    }
356}
357
358/// Tokens by scope or login, until shortly before they expire.
359fn tokens() -> &'static crate::cloud::cloud_command::Expiring<String> {
360    static TOKENS: OnceLock<crate::cloud::cloud_command::Expiring<String>> = OnceLock::new();
361    TOKENS.get_or_init(Default::default)
362}
363
364/// A token for `scope` from the signed-in `az`.
365pub fn token(scope: &str, env: &Environment<'_>) -> Result<String, String> {
366    if let Some(token) = tokens().get(scope) {
367        return Ok(token);
368    }
369    let output = (env.run)(
370        "az",
371        &[
372            "account",
373            "get-access-token",
374            "--scope",
375            scope,
376            "--output",
377            "json",
378        ],
379    )
380    .map_err(|e| match e {
381        CommandError::Missing(_) => "needs the Azure CLI".to_string(),
382        CommandError::Failed(message) if message.contains("az login") => {
383            format!("not logged in: run az login ({})", message.trim())
384        }
385        other => other.to_string(),
386    })?;
387    let (token, expires) =
388        parse_token(&output).ok_or_else(|| "az returned no token".to_string())?;
389    cache_token(scope, token.clone(), expires);
390    Ok(token)
391}
392
393/// The token and expiry from `az account get-access-token --output json`. Newer `az`
394/// gives `expires_on` in seconds; older gives only `expiresOn` in local time, which is
395/// not worth guessing at, so such a token is refreshed on the next request.
396pub fn parse_token(text: &str) -> Option<(String, Option<SystemTime>)> {
397    let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
398    let token = value.get("accessToken")?.as_str()?.to_string();
399    if token.is_empty() {
400        return None;
401    }
402    let expires = value
403        .get("expires_on")
404        .and_then(|v| v.as_u64().or_else(|| v.as_str()?.parse().ok()))
405        .map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs));
406    Some((token, expires))
407}
408
409/// The script Azure PowerShell runs: both scopes' tokens and expiries as JSON. A
410/// constant, with nothing interpolated into it. The token is a `SecureString` in recent
411/// Az.Accounts, and `NetworkCredential` turns it back into text in PowerShell 5.1 and 7
412/// alike.
413const POWERSHELL_SCRIPT: &str = "$ErrorActionPreference = 'Stop'; \
414Import-Module Az.Accounts; \
415$out = @{}; \
416foreach ($r in @('https://storage.azure.com/', 'https://management.azure.com/')) { \
417  $t = Get-AzAccessToken -ResourceUrl $r; \
418  $tok = $t.Token; \
419  if ($tok -is [System.Security.SecureString]) { $tok = [System.Net.NetworkCredential]::new('', $tok).Password }; \
420  $out[$r] = @{ token = $tok; expires = $t.ExpiresOn.ToUnixTimeSeconds() } \
421}; \
422$out | ConvertTo-Json -Compress";
423
424/// A token for `scope` from Azure PowerShell. One process fetches both scopes, and both
425/// are cached.
426fn powershell_token(scope: &str, env: &Environment<'_>) -> Result<String, String> {
427    let key = |scope: &str| format!("powershell {scope}");
428    if let Some(token) = tokens().get(&key(scope)) {
429        return Ok(token);
430    }
431    let args = [
432        "-NoProfile",
433        "-NonInteractive",
434        "-Command",
435        POWERSHELL_SCRIPT,
436    ];
437    let mut output = (env.run)("pwsh", &args);
438    if env.windows && matches!(output, Err(CommandError::Missing(_))) {
439        output = (env.run)("powershell", &args);
440    }
441    let output = output.map_err(|e| match e {
442        CommandError::Missing(_) => "needs PowerShell".to_string(),
443        CommandError::Failed(message) if message.contains("Connect-AzAccount") => {
444            format!("not signed in: run Connect-AzAccount ({})", message.trim())
445        }
446        CommandError::Failed(message) if message.contains("Az.Accounts") => {
447            "needs the Az.Accounts PowerShell module".to_string()
448        }
449        other => other.to_string(),
450    })?;
451    let tokens = parse_powershell_tokens(&output)
452        .ok_or_else(|| "Azure PowerShell returned no token".to_string())?;
453    let mut wanted = None;
454    for (resource, token, expires) in tokens {
455        let scope_of = format!("{resource}.default");
456        if scope_of == scope {
457            wanted = Some(token.clone());
458        }
459        cache_token(&key(&scope_of), token, Some(expires));
460    }
461    wanted.ok_or_else(|| format!("Azure PowerShell returned no token for {scope}"))
462}
463
464/// `(resource, token, expiry)` for each scope in the PowerShell script's output.
465pub fn parse_powershell_tokens(text: &str) -> Option<Vec<(String, String, SystemTime)>> {
466    let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
467    let tokens: Vec<_> = value
468        .as_object()?
469        .iter()
470        .filter_map(|(resource, entry)| {
471            let token = entry.get("token")?.as_str()?.to_string();
472            let expires = entry.get("expires")?.as_u64()?;
473            (!token.is_empty()).then(|| {
474                (
475                    resource.clone(),
476                    token,
477                    SystemTime::UNIX_EPOCH + Duration::from_secs(expires),
478                )
479            })
480        })
481        .collect();
482    (!tokens.is_empty()).then_some(tokens)
483}
484
485/// A token for `scope` for a service principal, from Entra ID's token endpoint: a client
486/// secret, or the federated token file read afresh, since AKS rotates it.
487fn service_principal_token(
488    sp: &ServicePrincipal,
489    scope: &str,
490    env: &Environment<'_>,
491) -> Result<String, String> {
492    let key = format!("sp {} {} {scope}", sp.tenant, sp.client_id);
493    if let Some(token) = tokens().get(&key) {
494        return Ok(token);
495    }
496    let mut form: Vec<(&str, String)> = vec![
497        ("client_id", sp.client_id.clone()),
498        ("scope", scope.to_string()),
499        ("grant_type", "client_credentials".to_string()),
500    ];
501    match (&sp.token_file, &sp.secret) {
502        (Some(file), _) => {
503            let assertion = (env.read)(file).ok_or_else(|| {
504                format!("cannot read AZURE_FEDERATED_TOKEN_FILE {}", file.display())
505            })?;
506            form.push((
507                "client_assertion_type",
508                "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".to_string(),
509            ));
510            form.push(("client_assertion", assertion.trim().to_string()));
511        }
512        (None, Some(secret)) => form.push(("client_secret", secret.clone())),
513        (None, None) => return Err("the service principal has no secret".to_string()),
514    }
515    let body = form
516        .iter()
517        .map(|(k, v)| format!("{k}={}", crate::cloud::cloud_browse::urlencode(v)))
518        .collect::<Vec<_>>()
519        .join("&");
520    let url = format!(
521        "{}/{}/oauth2/v2.0/token",
522        sp.authority.trim_end_matches('/'),
523        crate::cloud::cloud_browse::urlencode(&sp.tenant)
524    );
525    let mut response = crate::cloud::cloud_browse::http_agent()
526        .post(&url)
527        .config()
528        .http_status_as_error(false)
529        .build()
530        .header("Content-Type", "application/x-www-form-urlencoded")
531        .send(body)
532        .map_err(|e| format!("{e}"))?;
533    let status = response.status().as_u16();
534    let text = response
535        .body_mut()
536        .read_to_string()
537        .map_err(|e| format!("could not read the response: {e}"))?;
538    let (token, expires) = parse_entra_token(&text).ok_or_else(|| {
539        let value: Option<serde_json::Value> = serde_json::from_str(&text).ok();
540        let description = value
541            .as_ref()
542            .and_then(|v| v.get("error_description"))
543            .and_then(|d| d.as_str())
544            .and_then(|d| d.lines().next())
545            .unwrap_or("no token in the response");
546        format!("not logged in: the service principal was refused ({status}): {description}")
547    })?;
548    cache_token(&key, token.clone(), Some(expires));
549    Ok(token)
550}
551
552/// A token for `scope` from the platform's managed identity: App Service and Functions'
553/// `IDENTITY_ENDPOINT` (or the older `MSI_ENDPOINT`), else the VM's instance metadata
554/// service. Only reached when the platform or `instance_identity` allows it.
555fn managed_identity_token(scope: &str, env: &Environment<'_>) -> Result<String, String> {
556    let key = format!("managed {scope}");
557    if let Some(token) = tokens().get(&key) {
558        return Ok(token);
559    }
560    let resource = crate::cloud::cloud_browse::urlencode(scope.trim_end_matches(".default"));
561    let client = (env.var)("AZURE_CLIENT_ID")
562        .map(|id| format!("&client_id={}", crate::cloud::cloud_browse::urlencode(&id)))
563        .unwrap_or_default();
564    let set = |k: &str| (env.var)(k).filter(|v| !v.trim().is_empty());
565    let (url, header) = if let (Some(endpoint), Some(secret)) =
566        (set("IDENTITY_ENDPOINT"), set("IDENTITY_HEADER"))
567    {
568        (
569            format!("{endpoint}?api-version=2019-08-01&resource={resource}{client}"),
570            ("X-IDENTITY-HEADER", secret),
571        )
572    } else if let Some(endpoint) = set("MSI_ENDPOINT") {
573        // App Service's older endpoint takes a secret; Cloud Shell's takes none and
574        // wants the metadata header instead.
575        (
576            format!("{endpoint}?api-version=2017-09-01&resource={resource}{client}"),
577            match set("MSI_SECRET") {
578                Some(secret) => ("secret", secret),
579                None => ("Metadata", "true".to_string()),
580            },
581        )
582    } else {
583        (
584            format!(
585                "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={resource}{client}"
586            ),
587            ("Metadata", "true".to_string()),
588        )
589    };
590    let mut response = crate::cloud::user_agent::ureq_config()
591        .timeout_global(Some(Duration::from_secs(5)))
592        .http_status_as_error(false)
593        .build()
594        .new_agent()
595        .get(&url)
596        .header(header.0, &header.1)
597        .call()
598        .map_err(|e| format!("no managed identity answered: {e}"))?;
599    let status = response.status().as_u16();
600    let text = response
601        .body_mut()
602        .read_to_string()
603        .map_err(|e| format!("could not read the response: {e}"))?;
604    let (token, expires) = parse_entra_token(&text)
605        .ok_or_else(|| format!("the managed identity returned no token ({status})"))?;
606    cache_token(&key, token.clone(), Some(expires));
607    Ok(token)
608}
609
610/// The access token and expiry from an Entra ID token response.
611pub fn parse_entra_token(text: &str) -> Option<(String, SystemTime)> {
612    let value: serde_json::Value = serde_json::from_str(text).ok()?;
613    let token = value.get("access_token")?.as_str()?.to_string();
614    let number = |key: &str| {
615        value
616            .get(key)
617            .and_then(|v| v.as_u64().or_else(|| v.as_str()?.parse().ok()))
618    };
619    // Managed identity endpoints give `expires_on` in seconds since the epoch.
620    let expires = match (number("expires_on"), number("expires_in")) {
621        (Some(on), _) => SystemTime::UNIX_EPOCH + Duration::from_secs(on),
622        (None, Some(within)) => SystemTime::now() + Duration::from_secs(within),
623        (None, None) => SystemTime::now() + Duration::from_secs(300),
624    };
625    (!token.is_empty()).then_some((token, expires))
626}
627
628fn cache_token(key: &str, token: String, expires: Option<SystemTime>) {
629    crate::logging::keep_out_of_log(&token);
630    tokens().put(key, token, expires);
631}
632
633/// Account keys fetched after a 403, by account. In memory only: never cached to disk,
634/// logged or shown.
635fn account_keys() -> &'static Mutex<HashMap<String, String>> {
636    static KEYS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
637    KEYS.get_or_init(Default::default)
638}
639
640#[cfg(test)]
641pub(crate) fn remember_key_for_test(account: &str, key: &str) {
642    if let Ok(mut keys) = account_keys().lock() {
643        keys.insert(account.to_string(), key.to_string());
644    }
645}
646
647/// The key this session reads `account` with, when a token was refused and its keys
648/// were fetched.
649pub fn remembered_key(account: &str) -> Option<String> {
650    account_keys().lock().ok()?.get(account).cloned()
651}
652
653fn token_readable_accounts() -> &'static Mutex<std::collections::HashSet<String>> {
654    static ACCOUNTS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
655    ACCOUNTS.get_or_init(Default::default)
656}
657
658/// Whether a sign-in's token has read from `account` this session.
659pub fn token_reads(account: &str) -> bool {
660    token_readable_accounts()
661        .lock()
662        .is_ok_and(|accounts| accounts.contains(account))
663}
664
665/// Note that a sign-in's token read from `account`.
666pub fn remember_token_reads(account: &str) {
667    if let Ok(mut accounts) = token_readable_accounts().lock() {
668        accounts.insert(account.to_string());
669    }
670}
671
672/// Whether `settings` may read at `path` in `container`: one listing of at most one
673/// blob, which needs the same data permission a read does.
674pub fn check_read(
675    account: &str,
676    container: &str,
677    path: &str,
678    settings: &AzureSettings,
679) -> Result<(), String> {
680    let directory = match path.trim_start_matches('/').rsplit_once('/') {
681        Some((directory, _)) => format!("{directory}/"),
682        None => String::new(),
683    };
684    let url = format!(
685        "{}{}?restype=container&comp=list&maxresults=1&prefix={}",
686        settings.blob_endpoint_for(account),
687        crate::cloud::cloud_browse::urlencode(container),
688        crate::cloud::cloud_browse::urlencode(&directory)
689    );
690    send_signed(&url, account, settings).map(|_| ())
691}
692
693/// Whether a refusal is the one account keys get past: a sign-in with no data role.
694pub fn is_permission_mismatch(error: &str) -> bool {
695    error.contains("AuthorizationPermissionMismatch")
696}
697
698/// The first access key of `account`, as the Portal fetches them for someone with Owner
699/// or Contributor and no data role: Resource Graph for the account's ID and whether it
700/// allows shared keys, then `listKeys` with a management token from `identity`.
701pub fn fetch_account_key(
702    account: &str,
703    identity: &AzureAuth,
704    env: &Environment<'_>,
705) -> Result<String, String> {
706    if let Some(key) = remembered_key(account) {
707        return Ok(key);
708    }
709    let management = identity_token(identity, MANAGEMENT_SCOPE, env)?;
710    let query = format!(
711        "resources | where type =~ 'microsoft.storage/storageaccounts' and name =~ '{}' \
712         | project id, name, sharedKey=properties.allowSharedKeyAccess",
713        account.replace('\'', "")
714    );
715    let body = serde_json::json!({ "query": query });
716    let text = crate::cloud::cloud_browse::http_agent()
717        .post("https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01")
718        .header("Authorization", &format!("Bearer {management}"))
719        .header("Content-Type", "application/json")
720        .send(body.to_string())
721        .map_err(|e| format!("{e}"))?
722        .body_mut()
723        .read_to_string()
724        .map_err(|e| format!("could not read the response: {e}"))?;
725    let (id, shared_key) = parse_account_id(&text)
726        .ok_or_else(|| format!("{account} is not an account this login can manage"))?;
727    if !shared_key {
728        return Err("shared-key access is disabled on the account".to_string());
729    }
730    let mut response = crate::cloud::cloud_browse::http_agent()
731        .post(&format!(
732            "https://management.azure.com{id}/listKeys?api-version=2023-01-01"
733        ))
734        .config()
735        .http_status_as_error(false)
736        .build()
737        .header("Authorization", &format!("Bearer {management}"))
738        .header("Content-Length", "0")
739        .send_empty()
740        .map_err(|e| format!("{e}"))?;
741    let status = response.status().as_u16();
742    let text = response
743        .body_mut()
744        .read_to_string()
745        .map_err(|e| format!("could not read the response: {e}"))?;
746    if status != 200 {
747        return Err(format!(
748            "the login may not list the account's keys ({status})"
749        ));
750    }
751    let key = parse_keys(&text).ok_or_else(|| "the account returned no keys".to_string())?;
752    crate::logging::keep_out_of_log(&key);
753    if let Ok(mut keys) = account_keys().lock() {
754        keys.insert(account.to_string(), key.clone());
755    }
756    Ok(key)
757}
758
759/// The resource ID and shared-key setting of the one account a Resource Graph query
760/// found.
761pub fn parse_account_id(text: &str) -> Option<(String, bool)> {
762    let value: serde_json::Value = serde_json::from_str(text).ok()?;
763    let row = value.get("data")?.as_array()?.first()?;
764    let id = row.get("id")?.as_str()?.to_string();
765    let shared_key = row.get("sharedKey").and_then(|v| v.as_bool()) != Some(false);
766    Some((id, shared_key))
767}
768
769/// The first key from a `listKeys` response.
770pub fn parse_keys(text: &str) -> Option<String> {
771    let value: serde_json::Value = serde_json::from_str(text).ok()?;
772    value
773        .get("keys")?
774        .as_array()?
775        .iter()
776        .find_map(|k| k.get("value")?.as_str().map(str::to_string))
777        .filter(|k| !k.is_empty())
778}
779
780/// After a 403 on a sign-in's token: the same settings with the account's key, when
781/// the fallback is on and the account allows it, else the refusal with the reason.
782pub fn with_account_key(
783    account: &str,
784    settings: &AzureSettings,
785    refusal: &str,
786    enabled: bool,
787    env: &Environment<'_>,
788) -> Result<AzureSettings, String> {
789    let identity = match &settings.identity {
790        Some(identity) if enabled && is_permission_mismatch(refusal) => identity,
791        _ => return Err(refusal.to_string()),
792    };
793    match fetch_account_key(account, identity, env) {
794        Ok(key) => Ok(AzureSettings {
795            auth: AzureAuth::Key(key),
796            ..settings.clone()
797        }),
798        Err(why) => Err(format!(
799            "{refusal}. The account's keys were not used: {why}"
800        )),
801    }
802}
803
804/// One storage account, as Resource Graph describes it.
805#[derive(Debug, Clone, PartialEq, Eq, Default)]
806pub struct Account {
807    pub name: String,
808    pub subscription: Option<String>,
809    pub location: Option<String>,
810    pub hierarchical_namespace: bool,
811    pub blob_endpoint: Option<String>,
812    /// Public network access is disabled, or the firewall denies by default.
813    pub private_network: bool,
814    pub shared_key_access: bool,
815}
816
817/// The Resource Graph query for storage accounts, with each one's subscription name.
818const ACCOUNTS_QUERY: &str = "resources \
819| where type =~ 'microsoft.storage/storageaccounts' \
820| join kind=leftouter (resourcecontainers \
821    | where type =~ 'microsoft.resources/subscriptions' \
822    | project subscriptionId, subscription=name) on subscriptionId \
823| project id, name, subscription, location, \
824    hns=properties.isHnsEnabled, \
825    blob=properties.primaryEndpoints.blob, \
826    publicNetworkAccess=properties.publicNetworkAccess, \
827    defaultAction=properties.networkAcls.defaultAction, \
828    sharedKey=properties.allowSharedKeyAccess \
829| order by name asc";
830
831/// A Resource Graph response has more than this many pages only for a tenant nobody
832/// browses by scrolling.
833const MAX_ACCOUNT_PAGES: usize = 20;
834
835/// Every storage account the signed-in identity can see, across its subscriptions.
836pub fn discover_accounts(
837    identity: &AzureAuth,
838    env: &Environment<'_>,
839) -> Result<Vec<Account>, String> {
840    let token = identity_token(identity, MANAGEMENT_SCOPE, env)?;
841    crate::cloud::cloud_command::paged(MAX_ACCOUNT_PAGES, |skip| {
842        let mut options = serde_json::json!({ "$top": 1000 });
843        if let Some(skip) = skip {
844            options["$skipToken"] = serde_json::Value::String(skip.to_string());
845        }
846        let body = serde_json::json!({ "query": ACCOUNTS_QUERY, "options": options });
847        let text = crate::cloud::cloud_browse::http_agent()
848            .post("https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01")
849            .header("Authorization", &format!("Bearer {token}"))
850            .header("Content-Type", "application/json")
851            .send(body.to_string())
852            .map_err(|e| format!("{e}"))?
853            .body_mut()
854            .read_to_string()
855            .map_err(|e| format!("could not read the response: {e}"))?;
856        parse_accounts(&text)
857    })
858}
859
860/// The accounts in one Resource Graph response, and the token for the next page.
861pub fn parse_accounts(text: &str) -> Result<(Vec<Account>, Option<String>), String> {
862    let value: serde_json::Value =
863        serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
864    if let Some(message) = value
865        .get("error")
866        .and_then(|e| e.get("message"))
867        .and_then(|m| m.as_str())
868    {
869        return Err(message.to_string());
870    }
871    let rows = value
872        .get("data")
873        .and_then(|d| d.as_array())
874        .ok_or_else(|| "no data in the response".to_string())?;
875    let text_of = |row: &serde_json::Value, key: &str| {
876        row.get(key)
877            .and_then(|v| v.as_str())
878            .map(str::to_string)
879            .filter(|v| !v.is_empty())
880    };
881    let accounts = rows
882        .iter()
883        .filter_map(|row| {
884            Some(Account {
885                name: text_of(row, "name")?,
886                subscription: text_of(row, "subscription"),
887                location: text_of(row, "location"),
888                hierarchical_namespace: row.get("hns").and_then(|v| v.as_bool()) == Some(true),
889                blob_endpoint: text_of(row, "blob"),
890                private_network: text_of(row, "publicNetworkAccess").as_deref() == Some("Disabled")
891                    || text_of(row, "defaultAction").as_deref() == Some("Deny"),
892                // Unset means allowed.
893                shared_key_access: row.get("sharedKey").and_then(|v| v.as_bool()) != Some(false),
894            })
895        })
896        .collect();
897    let next = value
898        .get("$skipToken")
899        .and_then(|v| v.as_str())
900        .map(str::to_string);
901    Ok((accounts, next))
902}
903
904/// Containers in one account. `settings` must already hold a token or key, not `AzCli`.
905pub fn list_containers(account: &str, settings: &AzureSettings) -> Result<Vec<String>, String> {
906    let endpoint = settings.blob_endpoint_for(account);
907    let mut names = crate::cloud::cloud_command::paged(MAX_ACCOUNT_PAGES, |marker| {
908        let mut url = format!("{endpoint}?comp=list&maxresults=5000");
909        if let Some(marker) = marker {
910            url.push_str(&format!(
911                "&marker={}",
912                crate::cloud::cloud_browse::urlencode(marker)
913            ));
914        }
915        parse_containers(&send_signed(&url, account, settings)?)
916    })?;
917    names.sort();
918    Ok(names)
919}
920
921/// A signed GET, returning the body of a successful response and the service's own
922/// error code and message otherwise.
923fn send_signed(url: &str, account: &str, settings: &AzureSettings) -> Result<String, String> {
924    let url = match &settings.auth {
925        AzureAuth::Sas(sas) => format!("{url}&{sas}"),
926        _ => url.to_string(),
927    };
928    let mut request = http::Request::builder()
929        .method("GET")
930        .uri(&url)
931        // No `x-ms-date` of datui's own: the authorizer adds `Date` and signs it, and a
932        // second date header makes a shared-key signature one the service rejects.
933        .header("x-ms-version", API_VERSION)
934        .body(object_store::client::HttpRequestBody::empty())
935        .map_err(|e| format!("could not build the request: {e}"))?;
936    match &settings.auth {
937        AzureAuth::Bearer(token) => {
938            let credential = object_store::azure::AzureCredential::BearerToken(token.clone());
939            object_store::azure::AzureAuthorizer::new(&credential, account).authorize(&mut request);
940        }
941        AzureAuth::Key(key) => {
942            let key = object_store::azure::AzureAccessKey::try_new(key)
943                .map_err(|e| format!("the account key is not valid: {e}"))?;
944            let credential = object_store::azure::AzureCredential::AccessKey(key);
945            object_store::azure::AzureAuthorizer::new(&credential, account).authorize(&mut request);
946        }
947        AzureAuth::Sas(_)
948        | AzureAuth::None
949        | AzureAuth::AzCli
950        | AzureAuth::PowerShell
951        | AzureAuth::ServicePrincipal(_)
952        | AzureAuth::ManagedIdentity
953        | AzureAuth::KeyCommand(_) => {}
954    }
955    let mut call = crate::cloud::cloud_browse::http_agent()
956        .get(&url)
957        .config()
958        .http_status_as_error(false)
959        .build();
960    for (name, value) in request.headers() {
961        if let Ok(value) = value.to_str() {
962            call = call.header(name.as_str(), value);
963        }
964    }
965    let mut response = call.call().map_err(|e| format!("{e}"))?;
966    let status = response.status();
967    let text = response
968        .body_mut()
969        .read_to_string()
970        .map_err(|e| format!("could not read the response: {e}"))?;
971    if status.is_success() {
972        Ok(text)
973    } else {
974        Err(describe_error(status.as_u16(), &text))
975    }
976}
977
978/// `403 AuthorizationPermissionMismatch: ...` from an error document, with what fixes
979/// the one that trips up people who manage their storage in the Portal.
980pub fn describe_error(status: u16, body: &str) -> String {
981    let field = |name: &str| {
982        let open = format!("<{name}>");
983        let close = format!("</{name}>");
984        let start = body.find(&open)? + open.len();
985        let end = body[start..].find(&close)? + start;
986        Some(body[start..end].trim().to_string())
987    };
988    let code = field("Code").unwrap_or_default();
989    let message = field("Message")
990        .and_then(|m| m.lines().next().map(str::to_string))
991        .unwrap_or_default();
992    let mut text = format!("{status} {code}");
993    if !message.is_empty() {
994        text.push_str(&format!(": {message}"));
995    }
996    if code == "AuthorizationPermissionMismatch" {
997        text.push_str(
998            ". Reading blobs with a sign-in needs the Storage Blob Data Reader role on the \
999             account, or an ACL on the path when it has hierarchical namespace",
1000        );
1001    }
1002    text
1003}
1004
1005/// Container names from a `List Containers` response, and the marker for the next page.
1006pub fn parse_containers(text: &str) -> Result<(Vec<String>, Option<String>), String> {
1007    let mut names = Vec::new();
1008    let mut rest = text;
1009    while let Some(start) = rest.find("<Container>") {
1010        rest = &rest[start + "<Container>".len()..];
1011        let end = rest.find("</Container>").unwrap_or(rest.len());
1012        let block = &rest[..end];
1013        if let Some(name_start) = block.find("<Name>") {
1014            let after = &block[name_start + "<Name>".len()..];
1015            if let Some(name_end) = after.find("</Name>") {
1016                let name = after[..name_end].trim();
1017                if !name.is_empty() {
1018                    names.push(name.to_string());
1019                }
1020            }
1021        }
1022        rest = &rest[end..];
1023    }
1024    if names.is_empty() && !text.contains("<EnumerationResults") {
1025        return Err("not a container listing".to_string());
1026    }
1027    let next = text
1028        .find("<NextMarker>")
1029        .and_then(|start| {
1030            let after = &text[start + "<NextMarker>".len()..];
1031            after
1032                .find("</NextMarker>")
1033                .map(|end| after[..end].trim().to_string())
1034        })
1035        .filter(|m| !m.is_empty());
1036    Ok((names, next))
1037}
1038
1039/// An object store for one container. `settings` must already hold a token or key.
1040pub fn store(
1041    account: &str,
1042    container: &str,
1043    settings: &AzureSettings,
1044) -> Result<object_store::azure::MicrosoftAzure, String> {
1045    let mut builder = object_store::azure::MicrosoftAzureBuilder::new()
1046        .with_account(account)
1047        .with_container_name(container)
1048        .with_config(
1049            object_store::azure::AzureConfigKey::Client(crate::cloud::user_agent::CLIENT_KEY),
1050            crate::cloud::user_agent::get(),
1051        );
1052    if settings.use_emulator {
1053        builder = builder.with_use_emulator(true);
1054    }
1055    if let Some(endpoint) = &settings.blob_endpoint {
1056        builder = builder
1057            .with_endpoint(endpoint.trim_end_matches('/').to_string())
1058            .with_allow_http(endpoint.starts_with("http://"));
1059    }
1060    builder = match &settings.auth {
1061        AzureAuth::Bearer(token) => builder.with_bearer_token_authorization(token.clone()),
1062        AzureAuth::Key(key) => builder.with_access_key(key.clone()),
1063        AzureAuth::Sas(sas) => {
1064            builder.with_config(object_store::azure::AzureConfigKey::SasKey, sas.clone())
1065        }
1066        AzureAuth::None
1067        | AzureAuth::AzCli
1068        | AzureAuth::PowerShell
1069        | AzureAuth::ServicePrincipal(_)
1070        | AzureAuth::ManagedIdentity
1071        | AzureAuth::KeyCommand(_) => builder.with_skip_signature(true),
1072    };
1073    builder
1074        .build()
1075        .map_err(|e| format!("Azure is not configured: {e}"))
1076}
1077
1078/// Polars' view of the same settings, for `scan_parquet` on an `abfss://` URL.
1079pub fn polars_options(
1080    account: &str,
1081    settings: &AzureSettings,
1082) -> Vec<(object_store::azure::AzureConfigKey, String)> {
1083    use object_store::azure::AzureConfigKey;
1084    let mut options = vec![(AzureConfigKey::AccountName, account.to_string())];
1085    if settings.use_emulator {
1086        options.push((AzureConfigKey::UseEmulator, "true".to_string()));
1087    }
1088    if let Some(endpoint) = &settings.blob_endpoint {
1089        options.push((AzureConfigKey::Endpoint, endpoint.clone()));
1090    }
1091    match &settings.auth {
1092        AzureAuth::Bearer(token) => options.push((AzureConfigKey::Token, token.clone())),
1093        AzureAuth::Key(key) => options.push((AzureConfigKey::AccessKey, key.clone())),
1094        AzureAuth::Sas(sas) => options.push((AzureConfigKey::SasKey, sas.clone())),
1095        AzureAuth::None
1096        | AzureAuth::AzCli
1097        | AzureAuth::PowerShell
1098        | AzureAuth::ServicePrincipal(_)
1099        | AzureAuth::ManagedIdentity
1100        | AzureAuth::KeyCommand(_) => {
1101            options.push((AzureConfigKey::SkipSignature, "true".to_string()))
1102        }
1103    }
1104    options
1105}
1106
1107/// Whether a listed object is only a folder marker. Accounts with hierarchical
1108/// namespace list every directory twice, once as a prefix and once as an empty blob of
1109/// the same name; the blob is not data.
1110pub fn is_folder_marker(name: &str, size: u64, prefixes: &[String]) -> bool {
1111    size == 0 && prefixes.iter().any(|p| p.trim_end_matches('/') == name)
1112}
1113
1114#[cfg(test)]
1115mod tests;