1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use crate::{CamelError, Exchange};
7
8#[derive(Clone, PartialEq, Serialize, Deserialize)]
13pub struct Principal {
14 pub subject: String,
15 #[serde(default)]
16 pub issuer: String,
17 #[serde(default)]
18 pub audience: Vec<String>,
19 pub scopes: Vec<String>,
20 pub roles: Vec<String>,
21 pub claims: serde_json::Value,
22}
23
24impl Principal {
25 pub fn has_role(&self, role: &str) -> bool {
27 self.roles.iter().any(|r| r == role)
28 }
29
30 pub fn has_scope(&self, scope: &str) -> bool {
32 self.scopes.iter().any(|s| s == scope)
33 }
34}
35
36impl std::fmt::Debug for Principal {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.debug_struct("Principal")
41 .field("subject", &self.subject)
42 .field("issuer", &self.issuer)
43 .field("audience", &self.audience)
44 .field("scopes", &self.scopes)
45 .field("roles", &self.roles)
46 .field("claims", &"[REDACTED]")
47 .finish()
48 }
49}
50
51#[derive(Debug, Clone, PartialEq)]
52#[non_exhaustive]
53pub enum AuthorizationDecision {
54 Granted { principal: Principal },
60 Denied {
61 reason: String,
62 required: Vec<String>,
63 actual: Vec<String>,
64 },
65}
66
67impl std::fmt::Display for AuthorizationDecision {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 Self::Granted { principal } => {
71 write!(f, "Access granted for {}", principal.subject)
72 }
73 Self::Denied { reason, .. } => write!(f, "Access denied: {reason}"),
74 }
75 }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
86pub enum TransportId {
87 Http,
88 Ws,
89 Grpc,
90 Mcp,
91}
92
93pub trait AuthPrincipal: Send + Sync {
99 fn principal(&self) -> &Principal;
101 fn provider_id(&self) -> &str;
103}
104
105pub struct AuthContext<'a> {
111 pub principal: &'a dyn AuthPrincipal,
112 pub transport: TransportId,
113}
114
115#[async_trait]
116pub trait SecurityPolicy: Send + Sync {
117 async fn evaluate(
118 &self,
119 exchange: &mut Exchange,
120 auth: &AuthContext<'_>,
121 ) -> Result<AuthorizationDecision, CamelError>;
122}
123
124pub const CAMEL_HTTP_QUERY_HEADER: &str = "CamelHttpQuery";
131
132#[derive(Clone, PartialEq, Eq)]
138pub enum CredentialSource {
139 AuthorizationHeader,
141 QueryParam { param: String },
143 Cookie { name: String },
145 Header { name: String },
150}
151
152impl CredentialSource {
153 pub fn variant_name(&self) -> &'static str {
155 match self {
156 CredentialSource::AuthorizationHeader => "AuthorizationHeader",
157 CredentialSource::QueryParam { .. } => "QueryParam",
158 CredentialSource::Cookie { .. } => "Cookie",
159 CredentialSource::Header { .. } => "Header",
160 }
161 }
162}
163
164impl std::fmt::Debug for CredentialSource {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 CredentialSource::AuthorizationHeader => f.write_str("AuthorizationHeader"),
168 CredentialSource::QueryParam { param } => {
169 write!(f, "QueryParam {{ param: {:?} }}", param) }
171 CredentialSource::Cookie { name } => {
172 write!(f, "Cookie {{ name: {:?} }}", name) }
174 CredentialSource::Header { name } => {
175 write!(f, "Header {{ name: {:?} }}", name) }
177 }
178 }
179}
180
181pub struct SecurityPolicyConfig {
182 pub policy: Arc<dyn SecurityPolicy>,
183 pub credential_sources: Vec<CredentialSource>,
186}
187
188impl SecurityPolicyConfig {
189 pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
190 Self {
191 policy: Arc::new(policy),
192 credential_sources: vec![CredentialSource::AuthorizationHeader],
193 }
194 }
195
196 pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
197 Self {
198 policy,
199 credential_sources: vec![CredentialSource::AuthorizationHeader],
200 }
201 }
202
203 pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
204 self.credential_sources = sources;
205 self
206 }
207}
208
209impl Clone for SecurityPolicyConfig {
210 fn clone(&self) -> Self {
211 Self {
212 policy: Arc::clone(&self.policy),
213 credential_sources: self.credential_sources.clone(),
214 }
215 }
216}
217
218impl std::fmt::Debug for SecurityPolicyConfig {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 f.debug_struct("SecurityPolicyConfig")
221 .field("policy", &"<SecurityPolicy>")
222 .field("credential_sources", &self.credential_sources)
223 .finish()
224 }
225}
226
227pub enum AccessMode {
236 Public,
237 Authenticated,
238 Authorized(Arc<dyn SecurityPolicy>),
239}
240
241impl Clone for AccessMode {
242 fn clone(&self) -> Self {
243 match self {
244 Self::Public => Self::Public,
245 Self::Authenticated => Self::Authenticated,
246 Self::Authorized(policy) => Self::Authorized(Arc::clone(policy)),
247 }
248 }
249}
250
251impl std::fmt::Debug for AccessMode {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 match self {
254 Self::Public => f.write_str("Public"),
255 Self::Authenticated => f.write_str("Authenticated"),
256 Self::Authorized(_) => f.write_str("Authorized(<SecurityPolicy>)"),
257 }
258 }
259}
260
261#[derive(Clone, Debug, PartialEq)]
266pub struct AudienceBinding {
267 pub issuers: Vec<String>,
268 pub audiences: Vec<String>,
269}
270
271pub struct RouteSecurityPlan {
277 pub access_mode: AccessMode,
278 pub provider_ref: Option<String>,
279 pub transport: TransportId,
280 pub credential_sources: Vec<CredentialSource>,
281 pub audience_binding: Option<AudienceBinding>,
282}
283
284impl Clone for RouteSecurityPlan {
285 fn clone(&self) -> Self {
286 Self {
287 access_mode: self.access_mode.clone(),
288 provider_ref: self.provider_ref.clone(),
289 transport: self.transport,
290 credential_sources: self.credential_sources.clone(),
291 audience_binding: self.audience_binding.clone(),
292 }
293 }
294}
295
296impl std::fmt::Debug for RouteSecurityPlan {
297 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298 f.debug_struct("RouteSecurityPlan")
299 .field("access_mode", &self.access_mode)
300 .field("provider_ref", &self.provider_ref)
301 .field("transport", &self.transport)
302 .field("credential_sources", &self.credential_sources)
303 .field("audience_binding", &self.audience_binding)
304 .finish()
305 }
306}
307
308pub const PRINCIPAL_SUBJECT_KEY: &str = "camel.auth.subject";
312pub const PRINCIPAL_ROLES_KEY: &str = "camel.auth.roles";
314pub const PRINCIPAL_SCOPES_KEY: &str = "camel.auth.scopes";
316pub const PRINCIPAL_ISSUER_KEY: &str = "camel.auth.issuer";
318pub const PRINCIPAL_CLAIMS_KEY: &str = "camel.auth.claims";
320pub const PRINCIPAL_AUDIENCE_KEY: &str = "camel.auth.audience";
322pub const PRINCIPAL_KEY: &str = "camel.auth.principal";
324
325pub fn store_principal_properties(exchange: &mut Exchange, principal: &Principal) {
327 exchange.set_property(PRINCIPAL_SUBJECT_KEY, principal.subject.clone());
328 exchange.set_property(
329 PRINCIPAL_ROLES_KEY,
330 serde_json::to_string(&principal.roles).unwrap_or_default(),
331 );
332 exchange.set_property(
333 PRINCIPAL_SCOPES_KEY,
334 serde_json::to_string(&principal.scopes).unwrap_or_default(),
335 );
336 exchange.set_property(PRINCIPAL_ISSUER_KEY, principal.issuer.clone());
337 exchange.set_property(
338 PRINCIPAL_CLAIMS_KEY,
339 serde_json::to_string(&principal.claims).unwrap_or_default(),
340 );
341 exchange.set_property(
342 PRINCIPAL_AUDIENCE_KEY,
343 serde_json::to_string(&principal.audience).unwrap_or_default(),
344 );
345 exchange.set_property(
346 PRINCIPAL_KEY,
347 serde_json::to_string(principal).unwrap_or_default(),
348 );
349}
350
351pub fn principal_from_exchange(exchange: &Exchange) -> Option<Principal> {
352 exchange
353 .property(PRINCIPAL_KEY)
354 .and_then(|v| v.as_str())
355 .and_then(|s| serde_json::from_str(s).ok())
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::Body;
362
363 fn test_principal(roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
364 Principal {
365 subject: "user1".into(),
366 issuer: "test".into(),
367 audience: vec![],
368 scopes: scopes.into_iter().map(String::from).collect(),
369 roles: roles.into_iter().map(String::from).collect(),
370 claims: serde_json::Value::Null,
371 }
372 }
373
374 fn grant_policy() -> impl SecurityPolicy + 'static {
376 struct GrantPolicy;
377
378 #[async_trait]
379 impl SecurityPolicy for GrantPolicy {
380 async fn evaluate(
381 &self,
382 _exchange: &mut Exchange,
383 _auth: &AuthContext<'_>,
384 ) -> Result<AuthorizationDecision, CamelError> {
385 Ok(AuthorizationDecision::Granted {
386 principal: test_principal(vec![], vec![]),
387 })
388 }
389 }
390
391 GrantPolicy
392 }
393
394 #[test]
395 fn principal_has_role_is_case_sensitive() {
396 let p = test_principal(vec!["Admin", "User"], vec![]);
397 assert!(!p.has_role("admin"));
398 assert!(!p.has_role("ADMIN"));
399 assert!(p.has_role("User"));
400 assert!(!p.has_role("guest"));
401 }
402
403 #[test]
404 fn principal_has_scope() {
405 let p = test_principal(vec![], vec!["read", "write"]);
406 assert!(p.has_scope("read"));
407 assert!(!p.has_scope("delete"));
408 }
409
410 #[test]
411 fn authorization_decision_granted_display() {
412 let p = test_principal(vec![], vec![]);
413 let d = AuthorizationDecision::Granted { principal: p };
414 assert!(format!("{d}").contains("user1"));
415 }
416
417 #[test]
418 fn authorization_decision_denied_display() {
419 let d = AuthorizationDecision::Denied {
420 reason: "missing role".into(),
421 required: vec!["admin".into()],
422 actual: vec![],
423 };
424 assert!(format!("{d}").contains("missing role"));
425 }
426
427 #[test]
428 fn security_policy_config_debug_redacts_policy() {
429 let config = SecurityPolicyConfig::new(grant_policy());
430 let debug = format!("{config:?}");
431 assert!(debug.contains("SecurityPolicyConfig"));
432 assert!(debug.contains("<SecurityPolicy>"));
433 }
434
435 #[test]
436 fn security_policy_config_new_is_header_only() {
437 let config = SecurityPolicyConfig::new(grant_policy());
438 assert_eq!(
439 config.credential_sources,
440 vec![CredentialSource::AuthorizationHeader]
441 );
442 }
443
444 #[test]
445 fn store_principal_properties_populates_all_keys() {
446 let principal = Principal {
447 subject: "alice".into(),
448 issuer: "keycloak".into(),
449 audience: vec!["api".into()],
450 scopes: vec!["read".into(), "write".into()],
451 roles: vec!["admin".into()],
452 claims: serde_json::json!({"sub": "alice", "custom": true}),
453 };
454 let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
455 store_principal_properties(&mut exchange, &principal);
456
457 assert_eq!(
458 exchange.property(PRINCIPAL_SUBJECT_KEY).unwrap(),
459 &serde_json::Value::String("alice".into())
460 );
461 assert_eq!(
462 exchange.property(PRINCIPAL_ISSUER_KEY).unwrap(),
463 &serde_json::Value::String("keycloak".into())
464 );
465 let roles: Vec<String> = serde_json::from_str(
466 exchange
467 .property(PRINCIPAL_ROLES_KEY)
468 .unwrap()
469 .as_str()
470 .unwrap(),
471 )
472 .unwrap();
473 assert_eq!(roles, vec!["admin"]);
474 let scopes: Vec<String> = serde_json::from_str(
475 exchange
476 .property(PRINCIPAL_SCOPES_KEY)
477 .unwrap()
478 .as_str()
479 .unwrap(),
480 )
481 .unwrap();
482 assert_eq!(scopes, vec!["read", "write"]);
483 let audience: Vec<String> = serde_json::from_str(
484 exchange
485 .property(PRINCIPAL_AUDIENCE_KEY)
486 .unwrap()
487 .as_str()
488 .unwrap(),
489 )
490 .unwrap();
491 assert_eq!(audience, vec!["api"]);
492 let claims: serde_json::Value = serde_json::from_str(
493 exchange
494 .property(PRINCIPAL_CLAIMS_KEY)
495 .unwrap()
496 .as_str()
497 .unwrap(),
498 )
499 .unwrap();
500 assert!(claims.as_object().unwrap().contains_key("custom"));
501 let full: serde_json::Value =
502 serde_json::from_str(exchange.property(PRINCIPAL_KEY).unwrap().as_str().unwrap())
503 .unwrap();
504 assert_eq!(full["subject"], "alice");
505 }
506
507 #[test]
508 fn security_policy_config_clone() {
509 let config = SecurityPolicyConfig::new(grant_policy());
510 let cloned = config.clone();
511 assert!(Arc::ptr_eq(&config.policy, &cloned.policy));
513 }
514
515 #[test]
516 fn test_principal_from_exchange_round_trip() {
517 let principal = Principal {
518 subject: "bob".into(),
519 issuer: "keycloak".into(),
520 audience: vec!["api".into()],
521 scopes: vec!["read".into()],
522 roles: vec!["user".into()],
523 claims: serde_json::json!({"sub": "bob"}),
524 };
525 let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
526 store_principal_properties(&mut exchange, &principal);
527
528 let recovered = principal_from_exchange(&exchange).expect("principal should be recovered");
529 assert_eq!(recovered.subject, "bob");
530 assert_eq!(recovered.issuer, "keycloak");
531 assert_eq!(recovered.audience, vec!["api"]);
532 assert_eq!(recovered.scopes, vec!["read"]);
533 assert_eq!(recovered.roles, vec!["user"]);
534 }
535
536 #[test]
537 fn principal_debug_redacts_claims_compact() {
538 let principal = Principal {
539 subject: "subj-1".into(),
540 issuer: "iss".into(),
541 audience: vec!["a1".into()],
542 scopes: vec!["s1".into()],
543 roles: vec!["r1".into()],
544 claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
545 };
546 let s = format!("{principal:?}");
547 assert!(
548 s.contains("claims: \"[REDACTED]\""),
549 "compact debug should show [REDACTED] for claims"
550 );
551 assert!(
552 !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
553 "compact debug should NOT contain raw claim value"
554 );
555 assert!(s.contains("subj-1"), "compact debug should contain subject");
556 assert!(s.contains("iss"), "compact debug should contain issuer");
557 assert!(s.contains("a1"), "compact debug should contain audience");
558 assert!(s.contains("s1"), "compact debug should contain scopes");
559 assert!(s.contains("r1"), "compact debug should contain roles");
560 }
561
562 #[test]
563 fn principal_debug_redacts_claims_pretty() {
564 let principal = Principal {
565 subject: "subj-1".into(),
566 issuer: "iss".into(),
567 audience: vec!["a1".into()],
568 scopes: vec!["s1".into()],
569 roles: vec!["r1".into()],
570 claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
571 };
572 let s = format!("{principal:#?}");
573 assert!(
574 s.contains("[REDACTED]"),
575 "pretty debug should show [REDACTED]"
576 );
577 assert!(
578 !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
579 "pretty debug should NOT contain raw claim value"
580 );
581 }
582
583 #[test]
584 fn principal_serialize_preserves_claims() {
585 let principal = Principal {
586 subject: "subj-1".into(),
587 issuer: "iss".into(),
588 audience: vec!["a1".into()],
589 scopes: vec!["s1".into()],
590 roles: vec!["r1".into()],
591 claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
592 };
593 let s = serde_json::to_string(&principal).unwrap();
594 assert!(
595 s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
596 "serialization should preserve raw claim value"
597 );
598 }
599
600 #[test]
601 fn access_mode_debug_redacts_policy() {
602 let mode = AccessMode::Authorized(Arc::new(grant_policy()));
603 let debug = format!("{mode:?}");
604 assert!(debug.contains("<SecurityPolicy>"));
605 assert!(!debug.contains("GrantPolicy"));
606 }
607
608 #[test]
609 fn transport_id_derives_all_four() {
610 fn name(t: TransportId) -> &'static str {
611 match t {
612 TransportId::Http => "http",
613 TransportId::Ws => "ws",
614 TransportId::Grpc => "grpc",
615 TransportId::Mcp => "mcp",
616 }
617 }
618 assert_eq!(name(TransportId::Http), "http");
619 assert_eq!(name(TransportId::Ws), "ws");
620 assert_eq!(name(TransportId::Grpc), "grpc");
621 assert_eq!(name(TransportId::Mcp), "mcp");
622 }
623
624 #[test]
625 fn route_security_plan_clone_debug() {
626 let plan = RouteSecurityPlan {
627 access_mode: AccessMode::Authenticated,
628 provider_ref: Some("idp-a".to_string()),
629 transport: TransportId::Http,
630 credential_sources: vec![CredentialSource::AuthorizationHeader],
631 audience_binding: None,
632 };
633 let cloned = plan.clone();
634 assert_eq!(cloned.provider_ref, plan.provider_ref);
635 assert_eq!(cloned.transport, plan.transport);
636 assert_eq!(cloned.credential_sources, plan.credential_sources);
637 assert!(matches!(cloned.access_mode, AccessMode::Authenticated));
638 assert!(cloned.audience_binding.is_none());
639
640 let debug = format!("{plan:?}");
641 assert!(debug.contains(r#"provider_ref: Some("idp-a")"#));
642 assert!(!debug.contains("GrantPolicy"));
643 }
644}