Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions quickwit/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions quickwit/quickwit-config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

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
210 changes: 210 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,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;
Comment thread
deenkar marked this conversation as resolved.
}
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),
Comment thread
deenkar marked this conversation as resolved.
];

fn extract_azure_endpoint_host(endpoint: &str) -> Option<String> {
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<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 +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() {
{
Expand Down
Loading