From ec3ecedfd7519ae8679af340eec35f01c14777f3 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 24 May 2026 04:58:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20implement=20JWT=20session=20mint?= =?UTF-8?q?ing=20with=20filter=20injection=20(P5.21.c,=20=C2=A713.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add injected_filter, user, and groups claims to JwtClaims - Implement filter template rendering in oauth_proxy mode - Replace {groups} with JSON-encoded groups array - Replace {user} with user identifier - Bake rendered filter into JWT injected_filter claim - Apply injected_filter in search handler - AND injected_filter with user-supplied filter on every search - Pass filter through JWT claims extension - Add config validation: scoped_key_rotate_before_expiry_days < scoped_key_max_age_days - Add JwtClaimsExtension to pass claims from middleware to handlers - Update auth middleware to insert JWT claims into request extensions - Update sign_jwt to accept new optional filter fields Closes: miroir-uhj.21.3 Co-Authored-By: Claude Opus 4.7 --- crates/miroir-core/src/config/validate.rs | 32 +++++++ crates/miroir-proxy/src/auth.rs | 103 ++++++++++++++++++++-- crates/miroir-proxy/src/routes/search.rs | 28 +++++- crates/miroir-proxy/src/routes/session.rs | 63 +++++++++++-- 4 files changed, 211 insertions(+), 15 deletions(-) diff --git a/crates/miroir-core/src/config/validate.rs b/crates/miroir-core/src/config/validate.rs index 1e589c8..a05d416 100644 --- a/crates/miroir-core/src/config/validate.rs +++ b/crates/miroir-core/src/config/validate.rs @@ -386,4 +386,36 @@ mod tests { cfg.admin_ui.allowed_origins = vec!["same-origin".to_string()]; assert!(validate(&cfg).is_ok()); } + + #[test] + fn rejects_scoped_key_rotate_before_equal_to_max_age() { + let mut cfg = dev_config(); + cfg.search_ui.scoped_key_max_age_days = 60; + cfg.search_ui.scoped_key_rotate_before_expiry_days = 60; + let err = validate(&cfg).unwrap_err(); + assert!(err + .to_string() + .contains("scoped_key_rotate_before_expiry_days")); + assert!(err.to_string().contains("strictly less than")); + } + + #[test] + fn rejects_scoped_key_rotate_before_greater_than_max_age() { + let mut cfg = dev_config(); + cfg.search_ui.scoped_key_max_age_days = 30; + cfg.search_ui.scoped_key_rotate_before_expiry_days = 35; + let err = validate(&cfg).unwrap_err(); + assert!(err + .to_string() + .contains("scoped_key_rotate_before_expiry_days")); + assert!(err.to_string().contains("strictly less than")); + } + + #[test] + fn allows_scoped_key_rotate_before_less_than_max_age() { + let mut cfg = dev_config(); + cfg.search_ui.scoped_key_max_age_days = 60; + cfg.search_ui.scoped_key_rotate_before_expiry_days = 30; + assert!(validate(&cfg).is_ok()); + } } diff --git a/crates/miroir-proxy/src/auth.rs b/crates/miroir-proxy/src/auth.rs index 0e8febf..891e263 100644 --- a/crates/miroir-proxy/src/auth.rs +++ b/crates/miroir-proxy/src/auth.rs @@ -35,6 +35,11 @@ type HmacSha256 = Hmac; #[derive(Debug, Clone)] pub struct AdminSessionId(pub String); +/// Extension carried in the request after successful JWT validation. +/// Handlers extract this to access JWT claims like injected_filter. +#[derive(Debug, Clone)] +pub struct JwtClaimsExtension(pub JwtClaims); + /// State for CSRF middleware, combining AuthState with task store access. #[derive(Clone)] pub struct CsrfState { @@ -61,6 +66,13 @@ pub struct JwtClaims { pub iat: u64, /// Expiration timestamp (seconds since epoch). pub exp: u64, + /// Optional injected filter for oauth_proxy mode (plan §13.21). + /// When present, this filter is ANDed with any user-supplied filter. + pub injected_filter: Option, + /// User identifier from oauth_proxy headers (for observability). + pub user: Option, + /// Groups from oauth_proxy headers (for observability). + pub groups: Option>, } /// Key ID embedded in the JWT header to identify which secret signed it. @@ -188,7 +200,16 @@ impl AuthState { /// Create a new signed JWT session token for the given index (plan §13.21). /// Always signs with the primary secret; `kid` header identifies it. /// Scope defaults to ["search", "multi_search", "beacon"] for search UI sessions. - pub fn sign_jwt(&self, sub: &str, idx: &str, scope: &[&str], ttl_s: u64) -> Option { + pub fn sign_jwt( + &self, + sub: &str, + idx: &str, + scope: &[&str], + ttl_s: u64, + injected_filter: Option, + user: Option, + groups: Option>, + ) -> Option { let secret = self.jwt_primary.as_ref()?; let now = epoch_seconds(); let claims = JwtClaims { @@ -198,6 +219,9 @@ impl AuthState { scope: scope.iter().map(|s| s.to_string()).collect(), iat: now, exp: now + ttl_s, + injected_filter, + user, + groups, }; let header = JwtHeader { alg: "HS256".to_string(), @@ -799,6 +823,22 @@ pub async fn auth_middleware(State(state): State, req: Request, next: let verdict = dispatch_bearer(&method, &path, bearer, &state); match verdict { + AuthVerdict::Authenticated(TokenKind::Jwt) => { + // JWT validated successfully - extract claims and attach to request + if let Some(token) = bearer { + if let Ok(claims) = state.validate_jwt(token) { + let mut req = req; + req.extensions_mut().insert(JwtClaimsExtension(claims)); + return next.run(req).await; + } + } + // Shouldn't reach here if dispatch returned Jwt, but handle gracefully + MeilisearchError::new( + MiroirCode::JwtInvalid, + "The provided JWT is invalid or expired.", + ) + .into_response() + } AuthVerdict::Authenticated(_) | AuthVerdict::Exempt => next.run(req).await, AuthVerdict::JwtInvalid => MeilisearchError::new( MiroirCode::JwtInvalid, @@ -1312,7 +1352,7 @@ mod tests { fn sign_and_validate_primary_jwt() { let state = test_state_with_jwt(); let token = state - .sign_jwt("user1", "products", &["search"], 900) + .sign_jwt("user1", "products", &["search"], 900, None, None, None) .unwrap(); let claims = state.validate_jwt(&token).unwrap(); @@ -1325,7 +1365,7 @@ mod tests { fn signed_jwt_authenticates_via_dispatch() { let state = test_state_with_jwt(); let token = state - .sign_jwt("user1", "products", &["search"], 900) + .sign_jwt("user1", "products", &["search"], 900, None, None, None) .unwrap(); let verdict = dispatch_bearer(&Method::GET, "/indexes/products", Some(&token), &state); @@ -1343,6 +1383,9 @@ mod tests { scope: vec!["search".to_string()], iat: now - 3600, exp: now - 100, // expired well beyond 30s leeway + injected_filter: None, + user: None, + groups: None, }; let header = JwtHeader { alg: "HS256".to_string(), @@ -1364,7 +1407,7 @@ mod tests { fn tampered_signature_returns_invalid_signature() { let state = test_state_with_jwt(); let mut token = state - .sign_jwt("user1", "products", &["search"], 900) + .sign_jwt("user1", "products", &["search"], 900, None, None, None) .unwrap(); // Tamper with the signature let parts: Vec<&str> = token.split('.').collect(); @@ -1392,6 +1435,9 @@ mod tests { scope: vec!["search".to_string()], iat: now, exp: now + 900, + injected_filter: None, + user: None, + groups: None, }; let header = JwtHeader { alg: "HS256".to_string(), @@ -1427,7 +1473,9 @@ mod tests { #[test] fn rotation_new_token_validates_via_primary_secret() { let state = test_state_with_dual_jwt(); - let new_token = state.sign_jwt("user2", "orders", &["search"], 900).unwrap(); + let new_token = state + .sign_jwt("user2", "orders", &["search"], 900, None, None, None) + .unwrap(); let validated = state.validate_jwt(&new_token).unwrap(); assert_eq!(validated.sub, "user2"); @@ -1459,6 +1507,9 @@ mod tests { scope: vec!["search".to_string()], iat: now, exp: now + 900, + injected_filter: None, + user: None, + groups: None, }; let header = JwtHeader { alg: "HS256".to_string(), @@ -1515,7 +1566,7 @@ mod tests { // Tokens signed with current primary work let token = state - .sign_jwt("user1", "products", &["search"], 900) + .sign_jwt("user1", "products", &["search"], 900, None, None, None) .unwrap(); assert!(state.validate_jwt(&token).is_ok()); @@ -1527,6 +1578,9 @@ mod tests { scope: vec!["search".to_string()], iat: epoch_seconds() - 100, exp: epoch_seconds() + 800, + injected_filter: None, + user: None, + groups: None, }; let old_header = JwtHeader { alg: "HS256".to_string(), @@ -1565,7 +1619,9 @@ mod tests { )) .unwrap(), }; - let token_v1 = pre.sign_jwt("alice", "idx", &["search"], 900).unwrap(); + let token_v1 = pre + .sign_jwt("alice", "idx", &["search"], 900, None, None, None) + .unwrap(); assert!(pre.validate_jwt(&token_v1).is_ok()); // During rotation: v2 primary, v1 previous @@ -1585,7 +1641,9 @@ mod tests { // Old token still validates assert!(during.validate_jwt(&token_v1).is_ok()); // New tokens work too - let token_v2 = during.sign_jwt("bob", "idx", &["search"], 900).unwrap(); + let token_v2 = during + .sign_jwt("bob", "idx", &["search"], 900, None, None, None) + .unwrap(); assert!(during.validate_jwt(&token_v2).is_ok()); // Post-rotation: only v2 @@ -1908,6 +1966,9 @@ mod tests { ], iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search"); @@ -1927,6 +1988,9 @@ mod tests { ], iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/orders/search"); @@ -1942,6 +2006,9 @@ mod tests { scope: vec!["beacon".to_string()], // missing "search" iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search"); @@ -1961,6 +2028,9 @@ mod tests { ], iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search"); @@ -1976,6 +2046,9 @@ mod tests { scope: vec!["search".to_string()], // missing "multi_search" iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search"); @@ -1995,6 +2068,9 @@ mod tests { ], iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = @@ -2015,6 +2091,9 @@ mod tests { ], iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; let result = validate_jwt_scope(&claims, &Method::POST, "/_miroir/ui/search/orders/beacon"); @@ -2030,6 +2109,9 @@ mod tests { scope: vec!["search".to_string()], iat: epoch_seconds(), exp: epoch_seconds() + 900, + injected_filter: None, + user: None, + groups: None, }; // Endpoints that don't require scope validation should pass @@ -2042,7 +2124,7 @@ mod tests { fn dispatch_with_jwt_scope_denied_returns_scope_denied_verdict() { let state = test_state_with_jwt(); let token = state - .sign_jwt("user1", "products", &["search"], 900) + .sign_jwt("user1", "products", &["search"], 900, None, None, None) .unwrap(); // Token should be valid, but trying to use it on a different index should fail @@ -2064,6 +2146,9 @@ mod tests { "products", &["search", "multi_search", "beacon"], 900, + None, + None, + None, ) .unwrap(); diff --git a/crates/miroir-proxy/src/routes/search.rs b/crates/miroir-proxy/src/routes/search.rs index 89ce3e9..2343e5d 100644 --- a/crates/miroir-proxy/src/routes/search.rs +++ b/crates/miroir-proxy/src/routes/search.rs @@ -171,6 +171,7 @@ async fn search_handler( Path(index): Path, Extension(state): Extension>, session_id: Option>, + jwt_claims: Option>, headers: HeaderMap, Json(body): Json, ) -> Response { @@ -578,6 +579,31 @@ async fn search_handler( // Record scatter fan-out size before executing state.metrics.record_scatter_fan_out(node_count); + // Apply filter injection from JWT claims (plan §13.21) + // When a JWT has an injected_filter claim, AND it with any user-supplied filter + let filter = if let Some(Extension(jwt_ext)) = jwt_claims { + if let Some(ref injected_filter) = jwt_ext.0.injected_filter { + // JWT has an injected filter - AND it with user-supplied filter + match body.filter { + None => Some(serde_json::from_str(injected_filter).unwrap_or_else(|_| { + // If parse fails, treat as string filter + serde_json::json!(injected_filter) + })), + Some(ref user_filter) => { + // Combine filters: (user_filter) AND (injected_filter) + // Meilisearch filter syntax: ["user_filter", "injected_filter"] + Some(serde_json::json!([user_filter, injected_filter])) + } + } + } else { + // No injected filter, use user-supplied filter as-is + body.filter + } + } else { + // No JWT claims, use user-supplied filter as-is + body.filter + }; + // Build search request // Clone facets for fingerprinting before moving into SearchRequest let facets_clone = body.facets.clone(); @@ -587,7 +613,7 @@ async fn search_handler( query: body.q, offset: body.offset.unwrap_or(0), limit: body.limit.unwrap_or(20), - filter: body.filter, + filter, facets: body.facets, ranking_score: client_requested_score, body: rest_body, diff --git a/crates/miroir-proxy/src/routes/session.rs b/crates/miroir-proxy/src/routes/session.rs index 4299e83..3ba879d 100644 --- a/crates/miroir-proxy/src/routes/session.rs +++ b/crates/miroir-proxy/src/routes/session.rs @@ -338,8 +338,9 @@ where } // Authentication based on mode - let subject = match config.search_ui.auth.mode.as_str() { - "public" => "anonymous".to_string(), + let (subject, injected_filter, jwt_user, jwt_groups) = match config.search_ui.auth.mode.as_str() + { + "public" => ("anonymous".to_string(), None, None, None), "shared_key" => { let key = headers .get("X-Search-UI-Key") @@ -351,11 +352,16 @@ where ) })?; let expected_key = - std::env::var(&config.search_ui.auth.shared_key_env).unwrap_or_default(); + std::env::var(&config.search_ui.auth.shared_key_env).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("{} not set", config.search_ui.auth.shared_key_env), + ) + })?; if !crate::auth::constant_time_compare(key.as_bytes(), expected_key.as_bytes()) { return Err((StatusCode::UNAUTHORIZED, "invalid search UI key".into())); } - "shared_key_user".to_string() + ("shared_key_user".to_string(), None, None, None) } "oauth_proxy" => { let user = headers @@ -367,7 +373,51 @@ where format!("missing {}", config.search_ui.auth.oauth_proxy.user_header), ) })?; - user.to_string() + + // Extract groups header for filter template rendering + let groups_header = headers + .get(&config.search_ui.auth.oauth_proxy.groups_header) + .and_then(|h| h.to_str().ok()); + + let groups: Option> = groups_header.map(|gh| { + gh.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }); + + // Render filter template if configured (plan §13.21) + let injected_filter = config + .search_ui + .auth + .oauth_proxy + .filter_template + .as_ref() + .and_then(|template| { + let groups_array = groups.as_ref()?; + if groups_array.is_empty() { + return None; + } + + // JSON-encode the groups array for safe filter DSL injection + // e.g., ["engineering","ops"] for the IN operator + let groups_json = serde_json::to_string(groups_array).ok()?; + + // Replace {groups} placeholder with JSON-encoded array + let rendered = template.replace("{groups}", &groups_json); + + // Replace {user} placeholder + let rendered = rendered.replace("{user}", user); + + Some(rendered) + }); + + ( + user.to_string(), + injected_filter, + Some(user.to_string()), + groups, + ) } _ => { return Err(( @@ -403,6 +453,9 @@ where &index, &["search", "multi_search", "beacon"], config.search_ui.auth.session_ttl_s, + injected_filter, + jwt_user, + jwt_groups, ) .ok_or_else(|| { (