Skip to content

Commit e8e5a89

Browse files
authored
feat: [Geneva Uploader] Implement Azure Workload Identity authentication for Geneva. (#467)
1 parent 1ee6a9c commit e8e5a89

File tree

8 files changed

+704
-49
lines changed

8 files changed

+704
-49
lines changed

opentelemetry-exporter-geneva/geneva-uploader-ffi/src/lib.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,21 @@ pub unsafe extern "C" fn geneva_client_new(
288288

289289
// Auth method conversion
290290
let auth_method = match config.auth_method {
291-
// 0 => Managed Identity (default to system-assigned when coming from FFI for now)
292-
0 => AuthMethod::SystemManagedIdentity,
291+
0 => {
292+
// Unified: Workload Identity (AKS) or System Managed Identity (VM)
293+
// Auto-detect based on environment
294+
if std::env::var("AZURE_FEDERATED_TOKEN_FILE").is_ok() {
295+
// Workload Identity: azure_identity crate reads AZURE_CLIENT_ID, AZURE_TENANT_ID,
296+
// and AZURE_FEDERATED_TOKEN_FILE automatically from environment
297+
let resource = std::env::var("GENEVA_WORKLOAD_IDENTITY_RESOURCE")
298+
.unwrap_or_else(|_| "https://monitor.azure.com".to_string());
299+
300+
AuthMethod::WorkloadIdentity { resource }
301+
} else {
302+
AuthMethod::SystemManagedIdentity
303+
}
304+
}
305+
293306
1 => {
294307
// Certificate authentication: read fields from tagged union
295308
let cert = unsafe { config.auth.cert };

opentelemetry-exporter-geneva/geneva-uploader/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ serde = { version = "1.0", features = ["derive"] }
1616
serde_json = { version = "1.0", features = ["raw_value"] }
1717
uuid = { version = "1.0", features = ["v4"] }
1818
# TODO - support both native-tls and rustls
19-
reqwest = { version = "0.12", features = ["native-tls", "native-tls-alpn"], default-features = false}
20-
native-tls = "0.2"
19+
# http2 feature is required by hyper-util even when using http1_only()
20+
reqwest = { version = "0.12", features = ["native-tls", "native-tls-alpn", "http2"], default-features = false}
21+
native-tls = "0.2"
2122
thiserror = "2.0"
2223
chrono = "0.4"
2324
url = "2.2"

opentelemetry-exporter-geneva/geneva-uploader/src/client.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ impl GenevaClient {
5757
}
5858
}
5959
AuthMethod::Certificate { .. } => {}
60+
AuthMethod::WorkloadIdentity { .. } => {}
6061
#[cfg(feature = "mock_auth")]
6162
AuthMethod::MockAuth => {}
6263
}

opentelemetry-exporter-geneva/geneva-uploader/src/config_service/client.rs

Lines changed: 114 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Geneva Config Client with TLS (PKCS#12) and TODO: Managed Identity support
1+
// Geneva Config Client with TLS (PKCS#12) and Azure Workload Identity support TODO: Azure Arc support
22

33
use base64::{engine::general_purpose, Engine as _};
44
use reqwest::{
@@ -18,15 +18,20 @@ use std::fs;
1818
use std::path::PathBuf;
1919
use std::sync::RwLock;
2020

21-
// Azure Identity imports for MSI authentication
21+
// Azure Identity imports for MSI and Workload Identity authentication
2222
use azure_core::credentials::TokenCredential;
23-
use azure_identity::{ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId};
23+
use azure_identity::{
24+
ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId,
25+
WorkloadIdentityCredential,
26+
};
2427

2528
/// Authentication methods for the Geneva Config Client.
2629
///
27-
/// The client supports two authentication methods:
28-
/// - Certificate-based authentication using PKCS#12 (.p12) files
29-
/// - Managed Identity (Azure) - planned for future implementation
30+
/// The client supports the following authentication methods:
31+
/// - Certificate-based authentication (mTLS) using PKCS#12 (.p12) files
32+
/// - Azure Managed Identity (System-assigned or User-assigned)
33+
/// - Azure Workload Identity (Federated Identity for Kubernetes)
34+
/// - Mock authentication for testing (feature-gated)
3035
///
3136
/// # Certificate Format
3237
/// Certificates should be in PKCS#12 (.p12) format for client TLS authentication.
@@ -65,6 +70,19 @@ pub enum AuthMethod {
6570
UserManagedIdentityByObjectId { object_id: String },
6671
/// User-assigned managed identity by resource ID
6772
UserManagedIdentityByResourceId { resource_id: String },
73+
/// Azure Workload Identity authentication (Federated Identity for Kubernetes)
74+
///
75+
/// The following environment variables must be set in the pod spec:
76+
/// * `AZURE_CLIENT_ID` - Azure AD Application (client) ID (set explicitly in pod env)
77+
/// * `AZURE_TENANT_ID` - Azure AD Tenant ID (set explicitly in pod env)
78+
/// * `AZURE_FEDERATED_TOKEN_FILE` - Path to service account token file (auto-injected by workload identity webhook)
79+
///
80+
/// These variables are automatically read by the Azure Identity SDK at runtime.
81+
///
82+
/// # Arguments
83+
/// * `resource` - Azure AD resource URI for token acquisition
84+
/// (e.g., <https://monitor.azure.com> for Azure Public Cloud)
85+
WorkloadIdentity { resource: String },
6886
#[cfg(feature = "mock_auth")]
6987
MockAuth, // No authentication, used for testing purposes
7088
}
@@ -78,6 +96,8 @@ pub(crate) enum GenevaConfigClientError {
7896
JwtTokenError(String),
7997
#[error("Certificate error: {0}")]
8098
Certificate(String),
99+
#[error("Workload Identity authentication error: {0}")]
100+
WorkloadIdentityAuth(String),
81101
#[error("MSI authentication error: {0}")]
82102
MsiAuth(String),
83103

@@ -257,6 +277,10 @@ impl GenevaConfigClient {
257277
.map_err(|e| GenevaConfigClientError::Certificate(e.to_string()))?;
258278
client_builder = client_builder.use_preconfigured_tls(tls_connector);
259279
}
280+
AuthMethod::WorkloadIdentity { .. } => {
281+
// No special HTTP client configuration needed for Workload Identity
282+
// Authentication is done via Bearer token in request headers
283+
}
260284
AuthMethod::SystemManagedIdentity
261285
| AuthMethod::UserManagedIdentity { .. }
262286
| AuthMethod::UserManagedIdentityByObjectId { .. }
@@ -276,13 +300,14 @@ impl GenevaConfigClient {
276300
let version_str = format!("Ver{0}v0", config.config_major_version);
277301

278302
// Use different API endpoints based on authentication method
279-
// Certificate auth uses "api", MSI auth uses "userapi"
303+
// Certificate auth uses "api", MSI auth and Workload Identity use "userapi"
280304
let api_path = match &config.auth_method {
281305
AuthMethod::Certificate { .. } => "api",
282306
AuthMethod::SystemManagedIdentity
283307
| AuthMethod::UserManagedIdentity { .. }
284308
| AuthMethod::UserManagedIdentityByObjectId { .. }
285-
| AuthMethod::UserManagedIdentityByResourceId { .. } => "userapi",
309+
| AuthMethod::UserManagedIdentityByResourceId { .. }
310+
| AuthMethod::WorkloadIdentity { .. } => "userapi",
286311
#[cfg(feature = "mock_auth")]
287312
AuthMethod::MockAuth => "api", // treat mock like certificate path for URL shape
288313
};
@@ -329,6 +354,55 @@ impl GenevaConfigClient {
329354
headers
330355
}
331356

357+
/// Get Azure AD token using Workload Identity (Federated Identity)
358+
///
359+
/// Reads AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE from environment variables.
360+
/// In Kubernetes:
361+
/// - AZURE_CLIENT_ID and AZURE_TENANT_ID must be set explicitly in the pod spec
362+
/// - AZURE_FEDERATED_TOKEN_FILE is auto-injected by the workload identity webhook
363+
async fn get_workload_identity_token(&self) -> Result<String> {
364+
let resource =
365+
match &self.config.auth_method {
366+
AuthMethod::WorkloadIdentity { resource } => resource,
367+
_ => return Err(GenevaConfigClientError::WorkloadIdentityAuth(
368+
"get_workload_identity_token called but auth method is not WorkloadIdentity"
369+
.to_string(),
370+
)),
371+
};
372+
373+
// TODO: Extract scope generation logic into helper function shared with get_msi_token()
374+
let base = resource.trim_end_matches("/.default").trim_end_matches('/');
375+
let mut scope_candidates: Vec<String> = vec![format!("{base}/.default"), base.to_string()];
376+
// TODO - below check is not required, as we alread trim "/"
377+
if !base.ends_with('/') {
378+
scope_candidates.push(format!("{base}/"));
379+
}
380+
381+
// TODO: Consider caching WorkloadIdentityCredential if profiling shows credential creation overhead
382+
// Pass None to let azure_identity crate read AZURE_CLIENT_ID, AZURE_TENANT_ID,
383+
// and AZURE_FEDERATED_TOKEN_FILE from environment variables automatically
384+
let credential = WorkloadIdentityCredential::new(None).map_err(|e| {
385+
GenevaConfigClientError::WorkloadIdentityAuth(format!(
386+
"Failed to create WorkloadIdentityCredential. Ensure AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE environment variables are set: {e}"
387+
))
388+
})?;
389+
390+
let mut last_err: Option<String> = None;
391+
for scope in &scope_candidates {
392+
//TODO - It looks like the get_token API accepts a slice of &str
393+
match credential.get_token(&[scope.as_str()], None).await {
394+
Ok(token) => return Ok(token.token.secret().to_string()),
395+
Err(e) => last_err = Some(e.to_string()),
396+
}
397+
}
398+
399+
let detail = last_err.unwrap_or_else(|| "no error detail".into());
400+
Err(GenevaConfigClientError::WorkloadIdentityAuth(format!(
401+
"Workload Identity token acquisition failed. Scopes tried: {scopes}. Last error: {detail}",
402+
scopes = scope_candidates.join(", ")
403+
)))
404+
}
405+
332406
/// Get MSI token for GCS authentication
333407
async fn get_msi_token(&self) -> Result<String> {
334408
let resource = self.config.msi_resource.as_ref().ok_or_else(|| {
@@ -337,17 +411,14 @@ impl GenevaConfigClient {
337411
)
338412
})?;
339413

340-
// Normalize resource (strip trailing "/.default" if provided by user)
414+
// TODO: Extract scope generation logic into helper function shared with get_workload_identity_token()
341415
let base = resource.trim_end_matches("/.default").trim_end_matches('/');
342-
343-
// Candidate scopes tried with Azure Identity
344416
let mut scope_candidates: Vec<String> = vec![format!("{base}/.default"), base.to_string()];
345-
// Add variant with trailing slash if not already present
417+
// TODO - below check is not required, as we alread trim "/"
346418
if !base.ends_with('/') {
347419
scope_candidates.push(format!("{base}/"));
348420
}
349421

350-
// Build credential based on selector
351422
let user_assigned_id = match &self.config.auth_method {
352423
AuthMethod::SystemManagedIdentity => None,
353424
AuthMethod::UserManagedIdentity { client_id } => {
@@ -367,6 +438,7 @@ impl GenevaConfigClient {
367438
}
368439
};
369440

441+
// TODO: Consider caching ManagedIdentityCredential if profiling shows credential creation overhead
370442
let options = ManagedIdentityCredentialOptions {
371443
user_assigned_id,
372444
..Default::default()
@@ -382,6 +454,7 @@ impl GenevaConfigClient {
382454
Err(e) => last_err = Some(e.to_string()),
383455
}
384456
}
457+
385458
let detail = last_err.unwrap_or_else(|| "no error detail".into());
386459
Err(GenevaConfigClientError::MsiAuth(format!(
387460
"Managed Identity token acquisition failed. Scopes tried: {scopes}. Last error: {detail}. IMDS fallback intentionally disabled.",
@@ -506,8 +579,8 @@ impl GenevaConfigClient {
506579

507580
/// Internal method that actually fetches data from Geneva Config Service
508581
async fn fetch_ingestion_info(&self) -> Result<(IngestionGatewayInfo, MonikerInfo)> {
509-
let tag_id = Uuid::new_v4().to_string(); //TODO - uuid is costly, check if counter is enough?
510-
let mut url = String::with_capacity(self.precomputed_url_prefix.len() + 50); // Pre-allocate with reasonable capacity
582+
let tag_id = Uuid::new_v4().to_string(); // TODO: consider cheaper counter if perf-critical
583+
let mut url = String::with_capacity(self.precomputed_url_prefix.len() + 50);
511584
write!(&mut url, "{}&TagId={tag_id}", self.precomputed_url_prefix).map_err(|e| {
512585
GenevaConfigClientError::InternalError(format!("Failed to write URL: {e}"))
513586
})?;
@@ -518,48 +591,44 @@ impl GenevaConfigClient {
518591

519592
request = request.header("x-ms-client-request-id", req_id);
520593

521-
// Add MSI authentication for managed identity auth method
594+
// Add appropriate authentication header
522595
match &self.config.auth_method {
596+
AuthMethod::WorkloadIdentity { .. } => {
597+
let token = self.get_workload_identity_token().await?;
598+
request = request.header(AUTHORIZATION, format!("Bearer {}", token));
599+
}
523600
AuthMethod::SystemManagedIdentity
524601
| AuthMethod::UserManagedIdentity { .. }
525602
| AuthMethod::UserManagedIdentityByObjectId { .. }
526603
| AuthMethod::UserManagedIdentityByResourceId { .. } => {
527-
let msi_token = self.get_msi_token().await?;
528-
request = request.header(AUTHORIZATION, format!("Bearer {}", msi_token));
604+
let token = self.get_msi_token().await?;
605+
request = request.header(AUTHORIZATION, format!("Bearer {}", token));
529606
}
530607
AuthMethod::Certificate { .. } => { /* mTLS only */ }
531608
#[cfg(feature = "mock_auth")]
532609
AuthMethod::MockAuth => { /* no auth header */ }
533610
}
534611

535-
// Log the request details for debugging
612+
// Send HTTP request
536613
let response = match request.send().await {
537-
Ok(response) => response,
538-
Err(e) => {
539-
return Err(GenevaConfigClientError::Http(e));
540-
}
614+
Ok(resp) => resp,
615+
Err(e) => return Err(GenevaConfigClientError::Http(e)),
541616
};
542617

543-
// Check if the response is successful
544618
let status = response.status();
545619
let body = response.text().await?;
620+
546621
if status.is_success() {
547-
let parsed = match serde_json::from_str::<GenevaResponse>(&body) {
548-
Ok(response) => response,
549-
Err(e) => {
550-
return Err(GenevaConfigClientError::AuthInfoNotFound(format!(
551-
"Failed to parse response: {e}"
552-
)));
553-
}
554-
};
622+
let parsed = serde_json::from_str::<GenevaResponse>(&body).map_err(|e| {
623+
GenevaConfigClientError::AuthInfoNotFound(format!("Failed to parse response: {e}"))
624+
})?;
555625

556626
for account in parsed.storage_account_keys {
557627
if account.is_primary_moniker && account.account_moniker_name.contains("diag") {
558628
let moniker_info = MonikerInfo {
559629
name: account.account_moniker_name,
560630
account_group: account.account_group_name,
561631
};
562-
563632
return Ok((parsed.ingestion_gateway_info, moniker_info));
564633
}
565634
}
@@ -610,16 +679,21 @@ fn extract_endpoint_from_token(token: &str) -> Result<String> {
610679
_ => payload.to_string(),
611680
};
612681

613-
// Decode the Base64-encoded payload into raw bytes with a more tolerant approach.
682+
// Decode the Base64-encoded payload into raw bytes.
683+
// Try URL-safe (with and without padding), then fall back to standard Base64.
614684
let decoded = match general_purpose::URL_SAFE_NO_PAD.decode(&payload) {
615685
Ok(b) => b,
616-
Err(e_url) => match general_purpose::STANDARD.decode(&payload) {
686+
Err(e_url_no_pad) => match general_purpose::URL_SAFE.decode(&payload) {
617687
Ok(b) => b,
618-
Err(e_std) => {
619-
return Err(GenevaConfigClientError::JwtTokenError(format!(
620-
"Failed to decode JWT (url_safe and standard): url_err={e_url}; std_err={e_std}"
621-
)))
622-
}
688+
Err(e_url_pad) => match general_purpose::STANDARD.decode(&payload) {
689+
Ok(b) => b,
690+
Err(e_std) => {
691+
return Err(GenevaConfigClientError::JwtTokenError(format!(
692+
"Failed to decode JWT (URL_SAFE_NO_PAD, URL_SAFE, and STANDARD): \
693+
no_pad_err={e_url_no_pad}; pad_err={e_url_pad}; std_err={e_std}"
694+
)))
695+
}
696+
},
623697
},
624698
};
625699

opentelemetry-exporter-geneva/geneva-uploader/src/config_service/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,19 @@ mod tests {
2020
namespace: "ns".to_string(),
2121
region: "region".to_string(),
2222
config_major_version: 1,
23-
auth_method: AuthMethod::SystemManagedIdentity,
23+
auth_method: AuthMethod::WorkloadIdentity {
24+
resource: "https://monitor.azure.com".to_string(),
25+
},
2426
msi_resource: None,
2527
};
2628

2729
assert_eq!(config.environment, "env");
2830
assert_eq!(config.account, "acct");
29-
assert!(matches!(
30-
config.auth_method,
31-
AuthMethod::SystemManagedIdentity
32-
));
31+
32+
match config.auth_method {
33+
AuthMethod::WorkloadIdentity { .. } => {}
34+
_ => panic!("expected WorkloadIdentity variant"),
35+
}
3336
}
3437

3538
fn generate_self_signed_p12() -> (NamedTempFile, String) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Dockerfile for Geneva Uploader Workload Identity Test
2+
#
3+
# This Dockerfile must be built from the repository root to access the workspace:
4+
# cd /path/to/opentelemetry-rust-contrib
5+
# docker build -f opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/Dockerfile -t geneva-uploader-test:latest .
6+
#
7+
# Or using ACR:
8+
# az acr build --registry <registry-name> --image geneva-uploader-test:latest \
9+
# --file opentelemetry-exporter-geneva/opentelemetry-exporter-geneva/examples/Dockerfile .
10+
11+
FROM rust:1.85-slim AS builder
12+
13+
# Install build dependencies
14+
RUN apt-get update && apt-get install -y \
15+
pkg-config \
16+
libssl-dev \
17+
&& rm -rf /var/lib/apt/lists/*
18+
19+
WORKDIR /app
20+
21+
# Copy the entire workspace from repository root
22+
COPY . .
23+
24+
# Build the example
25+
WORKDIR /app/opentelemetry-exporter-geneva/opentelemetry-exporter-geneva
26+
RUN cargo build --release --example basic_workload_identity_test
27+
28+
# Runtime stage
29+
FROM debian:bookworm-slim
30+
31+
# Install runtime dependencies
32+
RUN apt-get update && apt-get install -y \
33+
ca-certificates \
34+
libssl3 \
35+
&& rm -rf /var/lib/apt/lists/*
36+
37+
# Copy the binary
38+
COPY --from=builder /app/target/release/examples/basic_workload_identity_test /usr/local/bin/geneva-uploader-test
39+
40+
# Run as non-root user
41+
RUN useradd -m -u 1000 appuser
42+
USER appuser
43+
44+
ENTRYPOINT ["/usr/local/bin/geneva-uploader-test"]
45+

0 commit comments

Comments
 (0)