1use 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
19pub const STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
21pub const MANAGEMENT_SCOPE: &str = "https://management.azure.com/.default";
23pub const API_VERSION: &str = "2023-11-03";
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum AzureAuth {
30 #[default]
32 None,
33 AzCli,
36 PowerShell,
38 ServicePrincipal(ServicePrincipal),
40 ManagedIdentity,
42 KeyCommand(String),
44 Bearer(String),
46 Key(String),
48 Sas(String),
50}
51
52#[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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub struct AzureSettings {
107 pub account: Option<String>,
110 pub auth: AzureAuth,
111 pub blob_endpoint: Option<String>,
113 pub use_emulator: bool,
115 pub identity: Option<AzureAuth>,
118}
119
120impl AzureSettings {
121 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 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
144const AZURITE_ACCOUNT: &str = "devstoreaccount1";
146const AZURITE_KEY: &str =
147 "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
148
149pub 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 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
204pub 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
247pub 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
273pub 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
286pub 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 Some(
295 env.home
296 .as_ref()?
297 .join(".Azure")
298 .join("AzureRmContext.json"),
299 )
300}
301
302pub 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
332pub 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 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
358fn 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
364pub 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
393pub 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
409const 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
424fn 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
464pub 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
485fn 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
552fn 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 (
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
610pub 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 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
633fn 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
647pub 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
658pub fn token_reads(account: &str) -> bool {
660 token_readable_accounts()
661 .lock()
662 .is_ok_and(|accounts| accounts.contains(account))
663}
664
665pub 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
672pub 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
693pub fn is_permission_mismatch(error: &str) -> bool {
695 error.contains("AuthorizationPermissionMismatch")
696}
697
698pub 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
759pub 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
769pub 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
780pub 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#[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 pub private_network: bool,
814 pub shared_key_access: bool,
815}
816
817const 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
831const MAX_ACCOUNT_PAGES: usize = 20;
834
835pub 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
860pub 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 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
904pub 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
921fn 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 .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
978pub 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
1005pub 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
1039pub 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
1078pub 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
1107pub 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;