Skip to main content

camel_api/
security_policy.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use crate::{CamelError, Exchange};
7
8/// Represents an authenticated principal extracted from token claims.
9///
10/// Provider-neutral: the `ClaimsMapper` trait in `camel-auth` is responsible
11/// for mapping provider-specific claim shapes into this structure.
12#[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    /// Check if the principal has a specific role.
26    pub fn has_role(&self, role: &str) -> bool {
27        self.roles.iter().any(|r| r == role)
28    }
29
30    /// Check if the principal has a specific scope.
31    pub fn has_scope(&self, scope: &str) -> bool {
32        self.scopes.iter().any(|s| s == scope)
33    }
34}
35
36// Manual Debug redacts untrusted `claims` (PII leak fix rc-yv1m).
37// Do NOT add `Debug` to the #[derive(...)] above — it would reintroduce the leak.
38impl 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    /// Access granted.
55    ///
56    /// `principal` is the policy's advisory asserted principal, stored to
57    /// exchange properties for observability. It is NOT the authentication
58    /// identity; that always comes from the [`AuthContext`] principal.
59    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/// Transport kinds that can host a server route.
79///
80/// Closed set: `http:`, `ws:`, `grpc:`, and `mcp:` are the only transports
81/// that terminate requests and run authorization.
82///
83/// exhaustive-by-contract: closed transport set; a new transport is an
84/// architectural change, not additive growth.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
86pub enum TransportId {
87    Http,
88    Ws,
89    Grpc,
90    Mcp,
91}
92
93/// Read-only view of the principal minted by the authentication path.
94///
95/// camel-api must never name a `camel-auth` type (no dependency cycle), so the
96/// concrete `AuthenticatedPrincipal` lives in `camel-auth` and this crate
97/// consumes it through this trait.
98pub trait AuthPrincipal: Send + Sync {
99    /// The authenticated principal.
100    fn principal(&self) -> &Principal;
101    /// The identifier of the provider that minted this principal.
102    fn provider_id(&self) -> &str;
103}
104
105/// Authentication context handed to a [`SecurityPolicy`] during evaluation.
106///
107/// Carries the authenticated principal and the transport that carried the
108/// request. The `AuthContext` principal is the authentication identity; a
109/// policy's `Granted { principal }` value is advisory only.
110pub 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
124/// Name of the input header camel-http uses to carry the raw HTTP query string.
125///
126/// camel-http stores the request query (the part after `?`) as a string header
127/// under this exact name (`crates/components/camel-http/src/lib.rs`).
128/// camel-auth cannot depend on camel-http, so this contract constant lives in
129/// camel-api. `QueryParam` extraction reads the raw query from this header.
130pub const CAMEL_HTTP_QUERY_HEADER: &str = "CamelHttpQuery";
131
132/// Source from which a token can be extracted.
133///
134/// exhaustive-by-contract: closed source set; out-of-crate camel-auth
135/// extraction matches all variants, so adding a source must update every
136/// match site by review.
137#[derive(Clone, PartialEq, Eq)]
138pub enum CredentialSource {
139    /// Extract from the `Authorization` header (Bearer scheme).
140    AuthorizationHeader,
141    /// Extract from a query parameter with the given name.
142    QueryParam { param: String },
143    /// Extract from a cookie with the given name.
144    Cookie { name: String },
145    /// Extract from a named request header (API-key style).
146    ///
147    /// The extracted value flows into the same constant-time
148    /// `NativeCredentialStore::lookup` as every other source.
149    Header { name: String },
150}
151
152impl CredentialSource {
153    /// Returns the variant name without exposing sensitive values.
154    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) // allow-secret
170            }
171            CredentialSource::Cookie { name } => {
172                write!(f, "Cookie {{ name: {:?} }}", name) // allow-secret
173            }
174            CredentialSource::Header { name } => {
175                write!(f, "Header {{ name: {:?} }}", name) // allow-secret
176            }
177        }
178    }
179}
180
181pub struct SecurityPolicyConfig {
182    pub policy: Arc<dyn SecurityPolicy>,
183    /// Extraction sources for the route's credential, in declared order.
184    /// Defaults to header-only (fail-closed, ADR-0033).
185    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
227/// Access classification for a compiled route.
228///
229/// `Public` needs no authentication. `Authenticated` requires a principal but
230/// no authorization policy. `Authorized` runs a policy against the
231/// authenticated principal.
232///
233/// exhaustive-by-contract: closed enforcement model; modes are kernel
234/// semantics, additions are deliberate breaking changes.
235pub 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/// Accepted issuer and audience set for a route's authentication.
262///
263/// Reserved in Phase 1; enforcement and cache-key participation arrive in
264/// Phase 3. Every configured accepted audience is kept.
265#[derive(Clone, Debug, PartialEq)]
266pub struct AudienceBinding {
267    pub issuers: Vec<String>,
268    pub audiences: Vec<String>,
269}
270
271/// Compiled security plan for a single server route.
272///
273/// Produced at staging time, before any listener binds. `access_mode`
274/// classifies the route; `provider_ref` is mandatory for
275/// `Authenticated`/`Authorized` and absent for `Public`.
276pub 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
308// --- Principal property storage helpers ---
309
310/// Exchange property key for the principal's subject.
311pub const PRINCIPAL_SUBJECT_KEY: &str = "camel.auth.subject";
312/// Exchange property key for the principal's roles (JSON array).
313pub const PRINCIPAL_ROLES_KEY: &str = "camel.auth.roles";
314/// Exchange property key for the principal's scopes (JSON array).
315pub const PRINCIPAL_SCOPES_KEY: &str = "camel.auth.scopes";
316/// Exchange property key for the principal's issuer.
317pub const PRINCIPAL_ISSUER_KEY: &str = "camel.auth.issuer";
318/// Exchange property key for the principal's raw claims (JSON object).
319pub const PRINCIPAL_CLAIMS_KEY: &str = "camel.auth.claims";
320/// Exchange property key for the principal's audience (JSON array).
321pub const PRINCIPAL_AUDIENCE_KEY: &str = "camel.auth.audience";
322/// Exchange property key for the full serialized principal.
323pub const PRINCIPAL_KEY: &str = "camel.auth.principal";
324
325/// Store all principal properties as exchange properties under well-known keys.
326pub 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    /// A `SecurityPolicy` that always grants to `test_principal(vec![], vec![])`.
375    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        // Both point to same Arc
512        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}