Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration/storage-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions quickwit/quickwit-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
164 changes: 164 additions & 0 deletions quickwit/quickwit-config/src/storage_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,107 @@ 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()
&& let Some(host) = extract_azure_endpoint_host(&endpoint)
{
return classify_azure_endpoint_host(host);
}
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"];

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.core.windows.net", AzureNationalCloud::Public),
Comment thread
deenkar marked this conversation as resolved.
];

fn extract_azure_endpoint_host(endpoint: &str) -> Option<&str> {
let endpoint = endpoint.trim();
let host = endpoint
.strip_prefix("https://")
.or_else(|| endpoint.strip_prefix("http://"))
.unwrap_or(endpoint);
host.split('/').next().filter(|host| !host.is_empty())
Comment thread
deenkar marked this conversation as resolved.
Outdated
}

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<AzureNationalCloud> {
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 {
Expand Down Expand Up @@ -729,6 +830,69 @@ 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
);
}

#[test]
fn test_storage_google_config_serde() {
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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<dyn azure_core::auth::TokenCredential>,
) -> Result<StorageCredentials, StorageResolverError> {
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,
Expand Down Expand Up @@ -690,9 +715,34 @@ impl From<AzureErrorWrapper> 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<azure_core::auth::AccessToken> {
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() {
Expand All @@ -713,4 +763,42 @@ 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<dyn TokenCredential>;

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_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<dyn TokenCredential>;

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<dyn TokenCredential>;

build_azure_token_credentials(&azure_storage_config, credential)
.expect("Azure China endpoint with token auth should succeed");
}
}