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