Skip to main content

micromegas_auth/
user_attribution.rs

1//! User attribution validation for preventing impersonation attacks
2//!
3//! This module provides utilities for validating user attribution headers against
4//! authenticated identity, preventing OIDC users from impersonating others while
5//! allowing service accounts (API keys) to delegate on behalf of users.
6//!
7//! This is specifically designed for gRPC services using tonic metadata.
8
9use micromegas_tracing::prelude::*;
10use percent_encoding::percent_decode_str;
11use tonic::{Status, metadata::MetadataMap};
12
13/// Resolved user attribution from gRPC metadata
14///
15/// **Audit-only. Must never feed a `ReadPolicy`/`ReadScope` resolution.** When
16/// `allow_delegation` is true and no `x-user-id`/`x-user-email` is present, or when no
17/// `AuthService` ran at all (`:145-152` below), this struct's fields fall back to
18/// client-supplied or "unknown" values -- that fallback is correct for its purpose (crediting a
19/// query to a human on whose behalf a service account is acting) and would be a client-controlled
20/// impersonation vector if it were ever used to decide what data a query may read. `ReadScope`
21/// resolution must read `AuthContext` from the request extension instead.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct UserAttribution {
24    /// The resolved user identifier (from x-user-id or auth token)
25    pub user_id: String,
26    /// The resolved user email (from x-user-email or auth token)
27    pub user_email: String,
28    /// The display name from x-user-name header (if provided)
29    pub user_name: Option<String>,
30    /// Service account name when delegation is being used
31    pub service_account: Option<String>,
32}
33
34/// Extract header value, decoding percent-encoded UTF-8
35/// Best-effort: logs warning and extracts printable chars on failure
36fn get_header_string_lossy(metadata: &MetadataMap, key: &str) -> Option<String> {
37    let value = metadata.get(key)?;
38
39    match value.to_str() {
40        Ok(s) => {
41            // Decode percent-encoded UTF-8
42            match percent_decode_str(s).decode_utf8() {
43                Ok(decoded) => Some(decoded.into_owned()),
44                Err(e) => {
45                    warn!("Header '{key}' has invalid percent-encoded UTF-8: {e}");
46                    Some(s.to_string()) // Use raw value as fallback
47                }
48            }
49        }
50        Err(_) => {
51            // Header contains non-ASCII bytes - log and extract what we can
52            let bytes = value.as_bytes();
53            let printable: String = bytes
54                .iter()
55                .filter(|&&b| (0x20..=0x7E).contains(&b))
56                .map(|&b| b as char)
57                .collect();
58
59            warn!(
60                "Header '{key}' contains non-ASCII bytes, extracted printable portion: '{printable}'"
61            );
62
63            if !printable.is_empty() {
64                Some(printable)
65            } else {
66                None
67            }
68        }
69    }
70}
71
72/// Extract the authenticated caller's admin status from gRPC metadata.
73///
74/// A missing `x-auth-is-admin` header means no `AuthService` is configured at all (e.g.
75/// `--disable-auth`) — `AuthService::call` rejects the request before it reaches the inner
76/// service when a provider is configured but validation fails, so the header is otherwise
77/// always present. Treat this case as trusted admin, matching the existing `--disable-auth`
78/// convention in `analytics-web-srv/src/web_server.rs`. An unparseable value fails closed to
79/// non-admin.
80pub fn is_admin(metadata: &MetadataMap) -> bool {
81    match metadata.get("x-auth-is-admin") {
82        None => true,
83        Some(v) => v
84            .to_str()
85            .ok()
86            .and_then(|s| s.parse::<bool>().ok())
87            .unwrap_or(false),
88    }
89}
90
91/// Validate and resolve user attribution from gRPC metadata
92///
93/// This function prevents user impersonation by validating x-user-id and x-user-email
94/// headers against the authenticated user's identity:
95///
96/// - **OIDC user tokens**: User identity MUST match token claims (no impersonation allowed)
97/// - **API keys/service accounts**: Can act on behalf of users (delegation allowed)
98/// - **Unauthenticated requests**: Pass through client-provided attribution
99///
100/// Header values support percent-encoded UTF-8 for international characters.
101/// Invalid headers are handled gracefully with logging.
102///
103/// # Arguments
104///
105/// * `metadata` - gRPC metadata map (tonic::metadata::MetadataMap) containing authentication
106///   and attribution headers
107///
108/// # Returns
109///
110/// Returns `Ok(UserAttribution)` containing:
111/// - `user_id`: The resolved user identifier
112/// - `user_email`: The resolved user email
113/// - `user_name`: The display name from x-user-name header (if provided)
114/// - `service_account`: `Some(name)` when delegation is being used, `None` otherwise
115///
116/// # Errors
117///
118/// Returns `Err(Box<Status::PermissionDenied>)` if an OIDC user attempts to impersonate another user.
119///
120/// # Example
121///
122/// ```rust
123/// use micromegas_auth::user_attribution::validate_and_resolve_user_attribution_grpc;
124/// use tonic::metadata::MetadataMap;
125///
126/// let mut metadata = MetadataMap::new();
127/// metadata.insert("x-auth-subject", "alice@example.com".parse().unwrap());
128/// metadata.insert("x-auth-email", "alice@example.com".parse().unwrap());
129/// metadata.insert("x-allow-delegation", "false".parse().unwrap());
130/// metadata.insert("x-user-id", "alice@example.com".parse().unwrap());
131///
132/// let result = validate_and_resolve_user_attribution_grpc(&metadata);
133/// assert!(result.is_ok());
134/// ```
135pub fn validate_and_resolve_user_attribution_grpc(
136    metadata: &MetadataMap,
137) -> Result<UserAttribution, Box<Status>> {
138    // Extract authentication context from headers (set by AuthService tower layer)
139    let auth_subject = metadata.get("x-auth-subject").and_then(|v| v.to_str().ok());
140    let auth_email = metadata.get("x-auth-email").and_then(|v| v.to_str().ok());
141    let allow_delegation = metadata
142        .get("x-allow-delegation")
143        .and_then(|v| v.to_str().ok())
144        .and_then(|s| s.parse::<bool>().ok())
145        .unwrap_or(false);
146
147    // Extract claimed user attribution from client (with percent-decoding support)
148    let claimed_user_id = get_header_string_lossy(metadata, "x-user-id");
149    let claimed_user_email = get_header_string_lossy(metadata, "x-user-email");
150    let claimed_user_name = get_header_string_lossy(metadata, "x-user-name");
151
152    // If no authentication context, allow unauthenticated access with client-provided attribution
153    let Some(authenticated_subject) = auth_subject else {
154        return Ok(UserAttribution {
155            user_id: claimed_user_id.unwrap_or_else(|| "unknown".to_string()),
156            user_email: claimed_user_email.unwrap_or_else(|| "unknown".to_string()),
157            user_name: claimed_user_name,
158            service_account: None,
159        });
160    };
161
162    if allow_delegation {
163        // Service account - can delegate (act on behalf of users)
164        let has_delegation = claimed_user_id.is_some() || claimed_user_email.is_some();
165        let user_id = claimed_user_id.unwrap_or_else(|| authenticated_subject.to_string());
166        let user_email = claimed_user_email
167            .or_else(|| auth_email.map(|s| s.to_string()))
168            .unwrap_or_else(|| "service-account".to_string());
169
170        // Return service account name to indicate delegation
171        let service_account = if has_delegation {
172            Some(authenticated_subject.to_string())
173        } else {
174            None
175        };
176
177        Ok(UserAttribution {
178            user_id,
179            user_email,
180            user_name: claimed_user_name,
181            service_account,
182        })
183    } else {
184        // OIDC user token - must match token claims (no impersonation)
185
186        // Validate x-user-id matches token subject (if provided)
187        if let Some(ref claimed_id) = claimed_user_id
188            && claimed_id != authenticated_subject
189        {
190            return Err(Box::new(Status::permission_denied(format!(
191                "User impersonation not allowed: x-user-id '{}' does not match authenticated subject '{}'",
192                claimed_id, authenticated_subject
193            ))));
194        }
195
196        // Validate x-user-email matches token email (if both provided)
197        if let (Some(claimed_email), Some(authenticated_email)) = (&claimed_user_email, auth_email)
198            && claimed_email != authenticated_email
199        {
200            return Err(Box::new(Status::permission_denied(format!(
201                "User impersonation not allowed: x-user-email '{}' does not match authenticated email '{}'",
202                claimed_email, authenticated_email
203            ))));
204        }
205
206        // Use token claims as authoritative source
207        Ok(UserAttribution {
208            user_id: authenticated_subject.to_string(),
209            user_email: auth_email.unwrap_or("unknown").to_string(),
210            user_name: claimed_user_name,
211            service_account: None,
212        })
213    }
214}