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/// Validate and resolve user attribution from gRPC metadata
65///
66/// This function prevents user impersonation by validating x-user-id and x-user-email
67/// headers against the authenticated user's identity:
68///
69/// - **OIDC user tokens**: User identity MUST match token claims (no impersonation allowed)
70/// - **API keys/service accounts**: Can act on behalf of users (delegation allowed)
71/// - **Unauthenticated requests**: Pass through client-provided attribution
72///
73/// Header values support percent-encoded UTF-8 for international characters.
74/// Invalid headers are handled gracefully with logging.
75///
76/// # Arguments
77///
78/// * `metadata` - gRPC metadata map (tonic::metadata::MetadataMap) containing authentication
79///   and attribution headers
80///
81/// # Returns
82///
83/// Returns `Ok(UserAttribution)` containing:
84/// - `user_id`: The resolved user identifier
85/// - `user_email`: The resolved user email
86/// - `user_name`: The display name from x-user-name header (if provided)
87/// - `service_account`: `Some(name)` when delegation is being used, `None` otherwise
88///
89/// # Errors
90///
91/// Returns `Err(Box<Status::PermissionDenied>)` if an OIDC user attempts to impersonate another user.
92///
93/// # Example
94///
95/// ```rust
96/// use micromegas_auth::user_attribution::validate_and_resolve_user_attribution_grpc;
97/// use tonic::metadata::MetadataMap;
98///
99/// let mut metadata = MetadataMap::new();
100/// metadata.insert("x-auth-subject", "alice@example.com".parse().unwrap());
101/// metadata.insert("x-auth-email", "alice@example.com".parse().unwrap());
102/// metadata.insert("x-allow-delegation", "false".parse().unwrap());
103/// metadata.insert("x-user-id", "alice@example.com".parse().unwrap());
104///
105/// let result = validate_and_resolve_user_attribution_grpc(&metadata);
106/// assert!(result.is_ok());
107/// ```
108pub fn validate_and_resolve_user_attribution_grpc(
109    metadata: &MetadataMap,
110) -> Result<UserAttribution, Box<Status>> {
111    // Extract authentication context from headers (set by AuthService tower layer)
112    let auth_subject = metadata.get("x-auth-subject").and_then(|v| v.to_str().ok());
113    let auth_email = metadata.get("x-auth-email").and_then(|v| v.to_str().ok());
114    let allow_delegation = metadata
115        .get("x-allow-delegation")
116        .and_then(|v| v.to_str().ok())
117        .and_then(|s| s.parse::<bool>().ok())
118        .unwrap_or(false);
119
120    // Extract claimed user attribution from client (with percent-decoding support)
121    let claimed_user_id = get_header_string_lossy(metadata, "x-user-id");
122    let claimed_user_email = get_header_string_lossy(metadata, "x-user-email");
123    let claimed_user_name = get_header_string_lossy(metadata, "x-user-name");
124
125    // If no authentication context, allow unauthenticated access with client-provided attribution
126    let Some(authenticated_subject) = auth_subject else {
127        return Ok(UserAttribution {
128            user_id: claimed_user_id.unwrap_or_else(|| "unknown".to_string()),
129            user_email: claimed_user_email.unwrap_or_else(|| "unknown".to_string()),
130            user_name: claimed_user_name,
131            service_account: None,
132        });
133    };
134
135    if allow_delegation {
136        // Service account - can delegate (act on behalf of users)
137        let has_delegation = claimed_user_id.is_some() || claimed_user_email.is_some();
138        let user_id = claimed_user_id.unwrap_or_else(|| authenticated_subject.to_string());
139        let user_email = claimed_user_email
140            .or_else(|| auth_email.map(|s| s.to_string()))
141            .unwrap_or_else(|| "service-account".to_string());
142
143        // Return service account name to indicate delegation
144        let service_account = if has_delegation {
145            Some(authenticated_subject.to_string())
146        } else {
147            None
148        };
149
150        Ok(UserAttribution {
151            user_id,
152            user_email,
153            user_name: claimed_user_name,
154            service_account,
155        })
156    } else {
157        // OIDC user token - must match token claims (no impersonation)
158
159        // Validate x-user-id matches token subject (if provided)
160        if let Some(ref claimed_id) = claimed_user_id
161            && claimed_id != authenticated_subject
162        {
163            return Err(Box::new(Status::permission_denied(format!(
164                "User impersonation not allowed: x-user-id '{}' does not match authenticated subject '{}'",
165                claimed_id, authenticated_subject
166            ))));
167        }
168
169        // Validate x-user-email matches token email (if both provided)
170        if let (Some(claimed_email), Some(authenticated_email)) = (&claimed_user_email, auth_email)
171            && claimed_email != authenticated_email
172        {
173            return Err(Box::new(Status::permission_denied(format!(
174                "User impersonation not allowed: x-user-email '{}' does not match authenticated email '{}'",
175                claimed_email, authenticated_email
176            ))));
177        }
178
179        // Use token claims as authoritative source
180        Ok(UserAttribution {
181            user_id: authenticated_subject.to_string(),
182            user_email: auth_email.unwrap_or("unknown").to_string(),
183            user_name: claimed_user_name,
184            service_account: None,
185        })
186    }
187}