diff --git a/CHANGELOG.md b/CHANGELOG.md index 1981b1a9dfa..36575744c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Azure Blob Storage: support custom endpoints via `endpoint` and `endpoint_suffix` configuration options for sovereign clouds (#6624) ### Fixed +- Azure Blob Storage: reject managed identity on unmapped custom endpoints and document sovereign-cloud `AZURE_AUTHORITY_HOST` requirements (#6624) - (Jaeger) Query resource attributes when Jaeger request carries tags ### Changed diff --git a/docs/configuration/storage-config.md b/docs/configuration/storage-config.md index df26af4ac68..2a02753fd5e 100644 --- a/docs/configuration/storage-config.md +++ b/docs/configuration/storage-config.md @@ -149,6 +149,22 @@ storage: endpoint_suffix: core.chinacloudapi.cn ``` +#### Managed identity on sovereign clouds + +When using managed identity or other token-based authentication (without an `access_key`) against Azure Government or Azure China, Quickwit uses the standard Azure Storage resource ID (`https://storage.azure.com/`) for OAuth tokens, together with the sovereign blob endpoint configured via `endpoint` or `endpoint_suffix`. + +Depending on the national cloud, you must set the Entra authority host so tokens are acquired from the correct login endpoint: + +```bash +# Azure US Government +export AZURE_AUTHORITY_HOST=https://login.microsoftonline.us/ + +# Azure China +export AZURE_AUTHORITY_HOST=https://login.chinacloudapi.cn/ +``` + +Custom endpoints that are not public Azure, Azure Government, or Azure China (for example Azure Stack) require an `access_key` when using token-based authentication. + ## Storage configuration examples for various object storage providers ### Garage diff --git a/quickwit/Cargo.lock b/quickwit/Cargo.lock index cd19a875db9..82505ec722d 100644 --- a/quickwit/Cargo.lock +++ b/quickwit/Cargo.lock @@ -8582,6 +8582,7 @@ dependencies = [ "tokio", "toml", "tracing", + "url", "utoipa", "vrl", ] diff --git a/quickwit/quickwit-config/Cargo.toml b/quickwit/quickwit-config/Cargo.toml index 44cae30260c..9a1dad75f3d 100644 --- a/quickwit/quickwit-config/Cargo.toml +++ b/quickwit/quickwit-config/Cargo.toml @@ -31,6 +31,7 @@ serde_yaml = { workspace = true } siphasher = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +url = "2" utoipa = { workspace = true } vrl = { workspace = true, optional = true } diff --git a/quickwit/quickwit-config/src/lib.rs b/quickwit/quickwit-config/src/lib.rs index 904e65f8180..a9fa004ad3d 100644 --- a/quickwit/quickwit-config/src/lib.rs +++ b/quickwit/quickwit-config/src/lib.rs @@ -85,9 +85,9 @@ pub use crate::node_config::{ pub use crate::serde_utils::HumanDuration; use crate::source_config::serialize::{SourceConfigV0_7, SourceConfigV0_8, VersionedSourceConfig}; pub use crate::storage_config::{ - AzureStorageConfig, ChecksumAlgorithm, FileStorageConfig, GoogleCloudStorageConfig, - RamStorageConfig, S3StorageConfig, StorageBackend, StorageBackendFlavor, StorageConfig, - StorageConfigs, + AzureNationalCloud, AzureStorageConfig, ChecksumAlgorithm, FileStorageConfig, + GoogleCloudStorageConfig, RamStorageConfig, S3StorageConfig, StorageBackend, + StorageBackendFlavor, StorageConfig, StorageConfigs, }; /// Returns true if the ingest API v2 is enabled. diff --git a/quickwit/quickwit-config/src/storage_config.rs b/quickwit/quickwit-config/src/storage_config.rs index 71684d350e7..7181ee526f4 100644 --- a/quickwit/quickwit-config/src/storage_config.rs +++ b/quickwit/quickwit-config/src/storage_config.rs @@ -357,6 +357,113 @@ impl AzureStorageConfig { }; Some(uri) } + + /// Returns `true` when a custom blob endpoint is configured. + pub fn uses_custom_blob_endpoint(&self) -> bool { + self.endpoint().is_some() || self.endpoint_suffix().is_some() + } + + /// Classifies the Azure national cloud from the configured endpoint. + pub fn resolve_national_cloud(&self) -> AzureNationalCloud { + if let Some(endpoint) = self.endpoint() { + if let Some(host) = extract_azure_endpoint_host(&endpoint) { + return classify_azure_endpoint_host(&host); + } + return AzureNationalCloud::Custom; + } + if let Some(endpoint_suffix) = self.endpoint_suffix() { + return classify_azure_endpoint_suffix(&endpoint_suffix); + } + AzureNationalCloud::Public + } +} + +/// Azure national cloud classification derived from the configured blob endpoint. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum AzureNationalCloud { + Public, + UsGovernment, + China, + Custom, +} + +const AZURE_PUBLIC_BLOB_SUFFIXES: &[&str] = &[ + "core.windows.net", + "blob.core.windows.net", + "blob.storage.azure.net", +]; + +const AZURE_US_GOVERNMENT_BLOB_SUFFIXES: &[&str] = + &["core.usgovcloudapi.net", "blob.core.usgovcloudapi.net"]; + +const AZURE_CHINA_BLOB_SUFFIXES: &[&str] = &["core.chinacloudapi.cn", "blob.core.chinacloudapi.cn"]; + +const AZURE_BLOB_HOST_SUFFIXES: &[(&str, AzureNationalCloud)] = &[ + (".blob.core.chinacloudapi.cn", AzureNationalCloud::China), + ( + ".blob.core.usgovcloudapi.net", + AzureNationalCloud::UsGovernment, + ), + (".blob.storage.azure.net", AzureNationalCloud::Public), + (".blob.core.windows.net", AzureNationalCloud::Public), +]; + +fn extract_azure_endpoint_host(endpoint: &str) -> Option { + let endpoint = endpoint.trim(); + let parsed_url = url::Url::parse(endpoint).ok()?; + if !parsed_url.username().is_empty() || parsed_url.password().is_some() { + return None; + } + parsed_url.host_str().map(str::to_string) +} + +fn strip_host_port(host: &str) -> &str { + if let Some(stripped_host) = host.strip_prefix('[') { + if let Some(bracket_end) = stripped_host.find(']') { + return &host[..bracket_end + 1]; + } + return host; + } + match host.rsplit_once(':') { + Some((host_without_port, port)) + if port.chars().all(|character| character.is_ascii_digit()) => + { + host_without_port + } + _ => host, + } +} + +fn national_cloud_from_exact_blob_suffix(blob_suffix: &str) -> Option { + let blob_suffix_lower = blob_suffix.trim().to_ascii_lowercase(); + if AZURE_CHINA_BLOB_SUFFIXES.contains(&blob_suffix_lower.as_str()) { + return Some(AzureNationalCloud::China); + } + if AZURE_US_GOVERNMENT_BLOB_SUFFIXES.contains(&blob_suffix_lower.as_str()) { + return Some(AzureNationalCloud::UsGovernment); + } + if AZURE_PUBLIC_BLOB_SUFFIXES.contains(&blob_suffix_lower.as_str()) { + return Some(AzureNationalCloud::Public); + } + None +} + +fn classify_azure_endpoint_host(host: &str) -> AzureNationalCloud { + let host_without_port = strip_host_port(host); + let host_lower = host_without_port.to_ascii_lowercase(); + if let Some(national_cloud) = national_cloud_from_exact_blob_suffix(&host_lower) { + return national_cloud; + } + for (host_suffix, national_cloud) in AZURE_BLOB_HOST_SUFFIXES { + if host_lower.ends_with(host_suffix) { + return *national_cloud; + } + } + AzureNationalCloud::Custom +} + +fn classify_azure_endpoint_suffix(endpoint_suffix: &str) -> AzureNationalCloud { + national_cloud_from_exact_blob_suffix(endpoint_suffix).unwrap_or(AzureNationalCloud::Custom) } impl fmt::Debug for AzureStorageConfig { @@ -729,6 +836,109 @@ mod tests { assert!(config.resolve_blob_service_uri("my-account").is_none()); } + #[test] + fn test_storage_azure_config_resolve_national_cloud() { + let public_config = AzureStorageConfig::default(); + assert_eq!( + public_config.resolve_national_cloud(), + AzureNationalCloud::Public + ); + + let gov_config = AzureStorageConfig { + endpoint_suffix: Some("core.usgovcloudapi.net".to_string()), + ..Default::default() + }; + assert_eq!( + gov_config.resolve_national_cloud(), + AzureNationalCloud::UsGovernment + ); + + let china_config = AzureStorageConfig { + endpoint: Some("https://my-account.blob.core.chinacloudapi.cn".to_string()), + ..Default::default() + }; + assert_eq!( + china_config.resolve_national_cloud(), + AzureNationalCloud::China + ); + + let custom_config = AzureStorageConfig { + endpoint: Some("https://storage.example.com".to_string()), + ..Default::default() + }; + assert_eq!( + custom_config.resolve_national_cloud(), + AzureNationalCloud::Custom + ); + + let spoofed_gov_config = AzureStorageConfig { + endpoint: Some("https://blob.core.usgovcloudapi.net.example.com".to_string()), + ..Default::default() + }; + assert_eq!( + spoofed_gov_config.resolve_national_cloud(), + AzureNationalCloud::Custom + ); + + let gov_with_port_config = AzureStorageConfig { + endpoint: Some("https://my-account.blob.core.usgovcloudapi.net:443".to_string()), + ..Default::default() + }; + assert_eq!( + gov_with_port_config.resolve_national_cloud(), + AzureNationalCloud::UsGovernment + ); + + let invalid_suffix_config = AzureStorageConfig { + endpoint_suffix: Some("evil.windows.net".to_string()), + ..Default::default() + }; + assert_eq!( + invalid_suffix_config.resolve_national_cloud(), + AzureNationalCloud::Custom + ); + + let query_spoof_config = AzureStorageConfig { + endpoint: Some( + "https://storage.example.com?x=.blob.core.usgovcloudapi.net".to_string(), + ), + ..Default::default() + }; + assert_eq!( + query_spoof_config.resolve_national_cloud(), + AzureNationalCloud::Custom + ); + + let dns_zone_config = AzureStorageConfig { + endpoint: Some("https://myaccount.z18.blob.storage.azure.net".to_string()), + ..Default::default() + }; + assert_eq!( + dns_zone_config.resolve_national_cloud(), + AzureNationalCloud::Public + ); + + let userinfo_with_gov_suffix_config = AzureStorageConfig { + endpoint: Some("https://user@storage.example.com".to_string()), + endpoint_suffix: Some("core.usgovcloudapi.net".to_string()), + ..Default::default() + }; + assert_eq!( + userinfo_with_gov_suffix_config.resolve_national_cloud(), + AzureNationalCloud::Custom + ); + + let unparseable_endpoint_config = AzureStorageConfig { + endpoint: Some("not-a-valid-url".to_string()), + endpoint_suffix: Some("core.usgovcloudapi.net".to_string()), + ..Default::default() + }; + assert_eq!( + unparseable_endpoint_config.resolve_national_cloud(), + AzureNationalCloud::Custom + ); + } + #[test] fn test_storage_google_config_serde() { { diff --git a/quickwit/quickwit-storage/src/object_storage/azure_blob_storage.rs b/quickwit/quickwit-storage/src/object_storage/azure_blob_storage.rs index cbbf6ea8ded..1a1055e19fa 100644 --- a/quickwit/quickwit-storage/src/object_storage/azure_blob_storage.rs +++ b/quickwit/quickwit-storage/src/object_storage/azure_blob_storage.rs @@ -34,7 +34,7 @@ use md5::Digest; use quickwit_common::retry::{RetryParams, Retryable, retry}; use quickwit_common::uri::Uri; use quickwit_common::{chunk_range, ignore_error_kind, into_u64_range}; -use quickwit_config::{AzureStorageConfig, StorageBackend}; +use quickwit_config::{AzureNationalCloud, AzureStorageConfig, StorageBackend}; use quickwit_metrics::HistogramTimer; use regex::Regex; use tantivy::directory::OwnedBytes; @@ -185,7 +185,7 @@ impl AzureBlobStorage { { StorageCredentials::access_key(storage_account_name.clone(), access_key) } else if let Ok(credential) = azure_identity::create_credential() { - StorageCredentials::token_credential(credential) + build_azure_token_credentials(azure_storage_config, credential)? } else { return Err(StorageResolverError::InvalidConfig( "could not find Azure storage account credentials using the following credential \ @@ -563,6 +563,31 @@ async fn extract_range_data_and_hash( Ok((data, hash)) } +fn build_azure_token_credentials( + azure_storage_config: &AzureStorageConfig, + credential: Arc, +) -> Result { + let national_cloud = azure_storage_config.resolve_national_cloud(); + if national_cloud == AzureNationalCloud::Custom + && azure_storage_config.uses_custom_blob_endpoint() + { + return Err(StorageResolverError::InvalidConfig( + "custom Azure blob endpoints require an access key when using token-based \ + authentication; managed identity is only supported for public Azure, Azure \ + Government, and Azure China endpoints" + .to_string(), + )); + } + if national_cloud != AzureNationalCloud::Public { + info!( + national_cloud = ?national_cloud, + "using Azure blob storage endpoint for sovereign cloud; ensure AZURE_AUTHORITY_HOST \ + is configured for token acquisition when using managed identity" + ); + } + Ok(StorageCredentials::token_credential(credential)) +} + fn build_container_client( storage_account_name: String, storage_credentials: StorageCredentials, @@ -690,9 +715,34 @@ impl From for StorageError { #[cfg(test)] mod tests { + use std::sync::Arc; + + use azure_core::auth::TokenCredential; use quickwit_common::uri::Uri; + use quickwit_config::AzureStorageConfig; + + use crate::StorageResolverError; + use crate::object_storage::azure_blob_storage::{ + build_azure_token_credentials, parse_azure_uri, + }; + + #[derive(Debug)] + struct MockTokenCredential; + + #[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] + #[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] + impl TokenCredential for MockTokenCredential { + async fn get_token( + &self, + _scopes: &[&str], + ) -> azure_core::Result { + unimplemented!("mock credential should not request tokens in these unit tests") + } - use crate::object_storage::azure_blob_storage::parse_azure_uri; + async fn clear_cache(&self) -> azure_core::Result<()> { + Ok(()) + } + } #[test] fn test_parse_azure_uri() { @@ -713,4 +763,57 @@ mod tests { assert_eq!(container, "test-container"); assert_eq!(prefix.to_str().unwrap(), "indexes"); } + + #[test] + fn test_build_azure_token_credentials_rejects_unknown_custom_endpoint() { + let azure_storage_config = AzureStorageConfig { + endpoint: Some("https://storage.example.com".to_string()), + ..Default::default() + }; + let credential = Arc::new(MockTokenCredential) as Arc; + + let error = build_azure_token_credentials(&azure_storage_config, credential) + .expect_err("custom endpoint with token auth should fail"); + + assert!(matches!(error, StorageResolverError::InvalidConfig(_))); + } + + #[test] + fn test_build_azure_token_credentials_rejects_unparseable_endpoint() { + let azure_storage_config = AzureStorageConfig { + endpoint: Some("https://user@storage.example.com".to_string()), + endpoint_suffix: Some("core.usgovcloudapi.net".to_string()), + ..Default::default() + }; + let credential = Arc::new(MockTokenCredential) as Arc; + + let error = build_azure_token_credentials(&azure_storage_config, credential) + .expect_err("unparseable endpoint with token auth should fail"); + + assert!(matches!(error, StorageResolverError::InvalidConfig(_))); + } + + #[test] + fn test_build_azure_token_credentials_accepts_azure_government_endpoint() { + let azure_storage_config = AzureStorageConfig { + endpoint_suffix: Some("core.usgovcloudapi.net".to_string()), + ..Default::default() + }; + let credential = Arc::new(MockTokenCredential) as Arc; + + build_azure_token_credentials(&azure_storage_config, credential) + .expect("Azure Government endpoint with token auth should succeed"); + } + + #[test] + fn test_build_azure_token_credentials_accepts_azure_china_endpoint() { + let azure_storage_config = AzureStorageConfig { + endpoint_suffix: Some("core.chinacloudapi.cn".to_string()), + ..Default::default() + }; + let credential = Arc::new(MockTokenCredential) as Arc; + + build_azure_token_credentials(&azure_storage_config, credential) + .expect("Azure China endpoint with token auth should succeed"); + } }