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:`, `mcp:`, and `wasm:` (source
81/// routes hosting an http-listener) are the only transports that
82/// terminate requests and run authorization.
83///
84/// exhaustive-by-contract: closed transport set; a new transport is an
85/// architectural change, not additive growth.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
87pub enum TransportId {
88    Http,
89    Ws,
90    Grpc,
91    Mcp,
92    Wasm,
93}
94
95/// Read-only view of the principal minted by the authentication path.
96///
97/// camel-api must never name a `camel-auth` type (no dependency cycle), so the
98/// concrete `AuthenticatedPrincipal` lives in `camel-auth` and this crate
99/// consumes it through this trait.
100pub trait AuthPrincipal: Send + Sync {
101    /// The authenticated principal.
102    fn principal(&self) -> &Principal;
103    /// The identifier of the provider that minted this principal.
104    fn provider_id(&self) -> &str;
105}
106
107/// Authentication context handed to a [`SecurityPolicy`] during evaluation.
108///
109/// Carries the authenticated principal and the transport that carried the
110/// request. The `AuthContext` principal is the authentication identity; a
111/// policy's `Granted { principal }` value is advisory only.
112pub struct AuthContext<'a> {
113    pub principal: &'a dyn AuthPrincipal,
114    pub transport: TransportId,
115}
116
117#[async_trait]
118pub trait SecurityPolicy: Send + Sync {
119    async fn evaluate(
120        &self,
121        exchange: &mut Exchange,
122        auth: &AuthContext<'_>,
123    ) -> Result<AuthorizationDecision, CamelError>;
124}
125
126/// Name of the input header camel-http uses to carry the raw HTTP query string.
127///
128/// camel-http stores the request query (the part after `?`) as a string header
129/// under this exact name (`crates/components/camel-http/src/lib.rs`).
130/// camel-auth cannot depend on camel-http, so this contract constant lives in
131/// camel-api. `QueryParam` extraction reads the raw query from this header.
132pub const CAMEL_HTTP_QUERY_HEADER: &str = "CamelHttpQuery";
133
134/// Source from which a token can be extracted.
135///
136/// exhaustive-by-contract: closed source set; out-of-crate camel-auth
137/// extraction matches all variants, so adding a source must update every
138/// match site by review.
139#[derive(Clone, PartialEq, Eq)]
140pub enum CredentialSource {
141    /// Extract from the `Authorization` header (Bearer scheme).
142    AuthorizationHeader,
143    /// Extract from a query parameter with the given name.
144    QueryParam { param: String },
145    /// Extract from a cookie with the given name.
146    Cookie { name: String },
147    /// Extract from a named request header (API-key style).
148    ///
149    /// The extracted value flows into the same constant-time
150    /// `NativeCredentialStore::lookup` as every other source.
151    Header { name: String },
152}
153
154impl CredentialSource {
155    /// Returns the variant name without exposing sensitive values.
156    pub fn variant_name(&self) -> &'static str {
157        match self {
158            CredentialSource::AuthorizationHeader => "AuthorizationHeader",
159            CredentialSource::QueryParam { .. } => "QueryParam",
160            CredentialSource::Cookie { .. } => "Cookie",
161            CredentialSource::Header { .. } => "Header",
162        }
163    }
164}
165
166impl std::fmt::Debug for CredentialSource {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        match self {
169            CredentialSource::AuthorizationHeader => f.write_str("AuthorizationHeader"),
170            CredentialSource::QueryParam { param } => {
171                write!(f, "QueryParam {{ param: {:?} }}", param) // allow-secret
172            }
173            CredentialSource::Cookie { name } => {
174                write!(f, "Cookie {{ name: {:?} }}", name) // allow-secret
175            }
176            CredentialSource::Header { name } => {
177                write!(f, "Header {{ name: {:?} }}", name) // allow-secret
178            }
179        }
180    }
181}
182
183pub struct SecurityPolicyConfig {
184    pub policy: Arc<dyn SecurityPolicy>,
185    /// Extraction sources for the route's credential, in declared order.
186    /// Defaults to header-only (fail-closed, ADR-0033).
187    pub credential_sources: Vec<CredentialSource>,
188}
189
190impl SecurityPolicyConfig {
191    pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
192        Self {
193            policy: Arc::new(policy),
194            credential_sources: vec![CredentialSource::AuthorizationHeader],
195        }
196    }
197
198    pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
199        Self {
200            policy,
201            credential_sources: vec![CredentialSource::AuthorizationHeader],
202        }
203    }
204
205    pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
206        self.credential_sources = sources;
207        self
208    }
209}
210
211impl Clone for SecurityPolicyConfig {
212    fn clone(&self) -> Self {
213        Self {
214            policy: Arc::clone(&self.policy),
215            credential_sources: self.credential_sources.clone(),
216        }
217    }
218}
219
220impl std::fmt::Debug for SecurityPolicyConfig {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("SecurityPolicyConfig")
223            .field("policy", &"<SecurityPolicy>")
224            .field("credential_sources", &self.credential_sources)
225            .finish()
226    }
227}
228
229/// Access classification for a compiled route.
230///
231/// `Public` needs no authentication. `Authenticated` requires a principal but
232/// no authorization policy. `Authorized` runs a policy against the
233/// authenticated principal.
234///
235/// exhaustive-by-contract: closed enforcement model; modes are kernel
236/// semantics, additions are deliberate breaking changes.
237pub enum AccessMode {
238    Public,
239    Authenticated,
240    Authorized(Arc<dyn SecurityPolicy>),
241}
242
243impl Clone for AccessMode {
244    fn clone(&self) -> Self {
245        match self {
246            Self::Public => Self::Public,
247            Self::Authenticated => Self::Authenticated,
248            Self::Authorized(policy) => Self::Authorized(Arc::clone(policy)),
249        }
250    }
251}
252
253impl std::fmt::Debug for AccessMode {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        match self {
256            Self::Public => f.write_str("Public"),
257            Self::Authenticated => f.write_str("Authenticated"),
258            Self::Authorized(_) => f.write_str("Authorized(<SecurityPolicy>)"),
259        }
260    }
261}
262
263/// Accepted issuer and audience set for a route's authentication.
264///
265/// Reserved in Phase 1; enforcement and cache-key participation arrive in
266/// Phase 3. Every configured accepted audience is kept.
267#[derive(Clone, Debug, PartialEq)]
268pub struct AudienceBinding {
269    pub issuers: Vec<String>,
270    pub audiences: Vec<String>,
271}
272
273/// Compiled security plan for a single server route.
274///
275/// Produced at staging time, before any listener binds. `access_mode`
276/// classifies the route; `provider_ref` is mandatory for
277/// `Authenticated`/`Authorized` and absent for `Public`.
278pub struct RouteSecurityPlan {
279    pub access_mode: AccessMode,
280    pub provider_ref: Option<String>,
281    pub transport: TransportId,
282    pub credential_sources: Vec<CredentialSource>,
283    pub audience_binding: Option<AudienceBinding>,
284}
285
286impl Clone for RouteSecurityPlan {
287    fn clone(&self) -> Self {
288        Self {
289            access_mode: self.access_mode.clone(),
290            provider_ref: self.provider_ref.clone(),
291            transport: self.transport,
292            credential_sources: self.credential_sources.clone(),
293            audience_binding: self.audience_binding.clone(),
294        }
295    }
296}
297
298impl std::fmt::Debug for RouteSecurityPlan {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("RouteSecurityPlan")
301            .field("access_mode", &self.access_mode)
302            .field("provider_ref", &self.provider_ref)
303            .field("transport", &self.transport)
304            .field("credential_sources", &self.credential_sources)
305            .field("audience_binding", &self.audience_binding)
306            .finish()
307    }
308}
309
310// --- Principal property storage helpers ---
311
312/// Exchange property key for the principal's subject.
313pub const PRINCIPAL_SUBJECT_KEY: &str = "camel.auth.subject";
314/// Exchange property key for the principal's roles (JSON array).
315pub const PRINCIPAL_ROLES_KEY: &str = "camel.auth.roles";
316/// Exchange property key for the principal's scopes (JSON array).
317pub const PRINCIPAL_SCOPES_KEY: &str = "camel.auth.scopes";
318/// Exchange property key for the principal's issuer.
319pub const PRINCIPAL_ISSUER_KEY: &str = "camel.auth.issuer";
320/// Exchange property key for the principal's raw claims (JSON object).
321pub const PRINCIPAL_CLAIMS_KEY: &str = "camel.auth.claims";
322/// Exchange property key for the principal's audience (JSON array).
323pub const PRINCIPAL_AUDIENCE_KEY: &str = "camel.auth.audience";
324/// Exchange property key for the full serialized principal.
325pub const PRINCIPAL_KEY: &str = "camel.auth.principal";
326
327/// Store all principal properties as exchange properties under well-known keys.
328pub fn store_principal_properties(exchange: &mut Exchange, principal: &Principal) {
329    exchange.set_property(PRINCIPAL_SUBJECT_KEY, principal.subject.clone());
330    exchange.set_property(
331        PRINCIPAL_ROLES_KEY,
332        serde_json::to_string(&principal.roles).unwrap_or_default(),
333    );
334    exchange.set_property(
335        PRINCIPAL_SCOPES_KEY,
336        serde_json::to_string(&principal.scopes).unwrap_or_default(),
337    );
338    exchange.set_property(PRINCIPAL_ISSUER_KEY, principal.issuer.clone());
339    exchange.set_property(
340        PRINCIPAL_CLAIMS_KEY,
341        serde_json::to_string(&principal.claims).unwrap_or_default(),
342    );
343    exchange.set_property(
344        PRINCIPAL_AUDIENCE_KEY,
345        serde_json::to_string(&principal.audience).unwrap_or_default(),
346    );
347    exchange.set_property(
348        PRINCIPAL_KEY,
349        serde_json::to_string(principal).unwrap_or_default(),
350    );
351}
352
353pub fn principal_from_exchange(exchange: &Exchange) -> Option<Principal> {
354    exchange
355        .property(PRINCIPAL_KEY)
356        .and_then(|v| v.as_str())
357        .and_then(|s| serde_json::from_str(s).ok())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::Body;
364
365    fn test_principal(roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
366        Principal {
367            subject: "user1".into(),
368            issuer: "test".into(),
369            audience: vec![],
370            scopes: scopes.into_iter().map(String::from).collect(),
371            roles: roles.into_iter().map(String::from).collect(),
372            claims: serde_json::Value::Null,
373        }
374    }
375
376    /// A `SecurityPolicy` that always grants to `test_principal(vec![], vec![])`.
377    fn grant_policy() -> impl SecurityPolicy + 'static {
378        struct GrantPolicy;
379
380        #[async_trait]
381        impl SecurityPolicy for GrantPolicy {
382            async fn evaluate(
383                &self,
384                _exchange: &mut Exchange,
385                _auth: &AuthContext<'_>,
386            ) -> Result<AuthorizationDecision, CamelError> {
387                Ok(AuthorizationDecision::Granted {
388                    principal: test_principal(vec![], vec![]),
389                })
390            }
391        }
392
393        GrantPolicy
394    }
395
396    #[test]
397    fn principal_has_role_is_case_sensitive() {
398        let p = test_principal(vec!["Admin", "User"], vec![]);
399        assert!(!p.has_role("admin"));
400        assert!(!p.has_role("ADMIN"));
401        assert!(p.has_role("User"));
402        assert!(!p.has_role("guest"));
403    }
404
405    #[test]
406    fn principal_has_scope() {
407        let p = test_principal(vec![], vec!["read", "write"]);
408        assert!(p.has_scope("read"));
409        assert!(!p.has_scope("delete"));
410    }
411
412    #[test]
413    fn authorization_decision_granted_display() {
414        let p = test_principal(vec![], vec![]);
415        let d = AuthorizationDecision::Granted { principal: p };
416        assert!(format!("{d}").contains("user1"));
417    }
418
419    #[test]
420    fn authorization_decision_denied_display() {
421        let d = AuthorizationDecision::Denied {
422            reason: "missing role".into(),
423            required: vec!["admin".into()],
424            actual: vec![],
425        };
426        assert!(format!("{d}").contains("missing role"));
427    }
428
429    #[test]
430    fn security_policy_config_debug_redacts_policy() {
431        let config = SecurityPolicyConfig::new(grant_policy());
432        let debug = format!("{config:?}");
433        assert!(debug.contains("SecurityPolicyConfig"));
434        assert!(debug.contains("<SecurityPolicy>"));
435    }
436
437    #[test]
438    fn security_policy_config_new_is_header_only() {
439        let config = SecurityPolicyConfig::new(grant_policy());
440        assert_eq!(
441            config.credential_sources,
442            vec![CredentialSource::AuthorizationHeader]
443        );
444    }
445
446    #[test]
447    fn store_principal_properties_populates_all_keys() {
448        let principal = Principal {
449            subject: "alice".into(),
450            issuer: "keycloak".into(),
451            audience: vec!["api".into()],
452            scopes: vec!["read".into(), "write".into()],
453            roles: vec!["admin".into()],
454            claims: serde_json::json!({"sub": "alice", "custom": true}),
455        };
456        let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
457        store_principal_properties(&mut exchange, &principal);
458
459        assert_eq!(
460            exchange.property(PRINCIPAL_SUBJECT_KEY).unwrap(),
461            &serde_json::Value::String("alice".into())
462        );
463        assert_eq!(
464            exchange.property(PRINCIPAL_ISSUER_KEY).unwrap(),
465            &serde_json::Value::String("keycloak".into())
466        );
467        let roles: Vec<String> = serde_json::from_str(
468            exchange
469                .property(PRINCIPAL_ROLES_KEY)
470                .unwrap()
471                .as_str()
472                .unwrap(),
473        )
474        .unwrap();
475        assert_eq!(roles, vec!["admin"]);
476        let scopes: Vec<String> = serde_json::from_str(
477            exchange
478                .property(PRINCIPAL_SCOPES_KEY)
479                .unwrap()
480                .as_str()
481                .unwrap(),
482        )
483        .unwrap();
484        assert_eq!(scopes, vec!["read", "write"]);
485        let audience: Vec<String> = serde_json::from_str(
486            exchange
487                .property(PRINCIPAL_AUDIENCE_KEY)
488                .unwrap()
489                .as_str()
490                .unwrap(),
491        )
492        .unwrap();
493        assert_eq!(audience, vec!["api"]);
494        let claims: serde_json::Value = serde_json::from_str(
495            exchange
496                .property(PRINCIPAL_CLAIMS_KEY)
497                .unwrap()
498                .as_str()
499                .unwrap(),
500        )
501        .unwrap();
502        assert!(claims.as_object().unwrap().contains_key("custom"));
503        let full: serde_json::Value =
504            serde_json::from_str(exchange.property(PRINCIPAL_KEY).unwrap().as_str().unwrap())
505                .unwrap();
506        assert_eq!(full["subject"], "alice");
507    }
508
509    #[test]
510    fn security_policy_config_clone() {
511        let config = SecurityPolicyConfig::new(grant_policy());
512        let cloned = config.clone();
513        // Both point to same Arc
514        assert!(Arc::ptr_eq(&config.policy, &cloned.policy));
515    }
516
517    #[test]
518    fn test_principal_from_exchange_round_trip() {
519        let principal = Principal {
520            subject: "bob".into(),
521            issuer: "keycloak".into(),
522            audience: vec!["api".into()],
523            scopes: vec!["read".into()],
524            roles: vec!["user".into()],
525            claims: serde_json::json!({"sub": "bob"}),
526        };
527        let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
528        store_principal_properties(&mut exchange, &principal);
529
530        let recovered = principal_from_exchange(&exchange).expect("principal should be recovered");
531        assert_eq!(recovered.subject, "bob");
532        assert_eq!(recovered.issuer, "keycloak");
533        assert_eq!(recovered.audience, vec!["api"]);
534        assert_eq!(recovered.scopes, vec!["read"]);
535        assert_eq!(recovered.roles, vec!["user"]);
536    }
537
538    #[test]
539    fn principal_debug_redacts_claims_compact() {
540        let principal = Principal {
541            subject: "subj-1".into(),
542            issuer: "iss".into(),
543            audience: vec!["a1".into()],
544            scopes: vec!["s1".into()],
545            roles: vec!["r1".into()],
546            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
547        };
548        let s = format!("{principal:?}");
549        assert!(
550            s.contains("claims: \"[REDACTED]\""),
551            "compact debug should show [REDACTED] for claims"
552        );
553        assert!(
554            !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
555            "compact debug should NOT contain raw claim value"
556        );
557        assert!(s.contains("subj-1"), "compact debug should contain subject");
558        assert!(s.contains("iss"), "compact debug should contain issuer");
559        assert!(s.contains("a1"), "compact debug should contain audience");
560        assert!(s.contains("s1"), "compact debug should contain scopes");
561        assert!(s.contains("r1"), "compact debug should contain roles");
562    }
563
564    #[test]
565    fn principal_debug_redacts_claims_pretty() {
566        let principal = Principal {
567            subject: "subj-1".into(),
568            issuer: "iss".into(),
569            audience: vec!["a1".into()],
570            scopes: vec!["s1".into()],
571            roles: vec!["r1".into()],
572            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
573        };
574        let s = format!("{principal:#?}");
575        assert!(
576            s.contains("[REDACTED]"),
577            "pretty debug should show [REDACTED]"
578        );
579        assert!(
580            !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
581            "pretty debug should NOT contain raw claim value"
582        );
583    }
584
585    #[test]
586    fn principal_serialize_preserves_claims() {
587        let principal = Principal {
588            subject: "subj-1".into(),
589            issuer: "iss".into(),
590            audience: vec!["a1".into()],
591            scopes: vec!["s1".into()],
592            roles: vec!["r1".into()],
593            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
594        };
595        let s = serde_json::to_string(&principal).unwrap();
596        assert!(
597            s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
598            "serialization should preserve raw claim value"
599        );
600    }
601
602    #[test]
603    fn access_mode_debug_redacts_policy() {
604        let mode = AccessMode::Authorized(Arc::new(grant_policy()));
605        let debug = format!("{mode:?}");
606        assert!(debug.contains("<SecurityPolicy>"));
607        assert!(!debug.contains("GrantPolicy"));
608    }
609
610    #[test]
611    fn transport_id_derives_all() {
612        fn name(t: TransportId) -> &'static str {
613            match t {
614                TransportId::Http => "http",
615                TransportId::Ws => "ws",
616                TransportId::Grpc => "grpc",
617                TransportId::Mcp => "mcp",
618                TransportId::Wasm => "wasm",
619            }
620        }
621        assert_eq!(name(TransportId::Http), "http");
622        assert_eq!(name(TransportId::Ws), "ws");
623        assert_eq!(name(TransportId::Grpc), "grpc");
624        assert_eq!(name(TransportId::Mcp), "mcp");
625        assert_eq!(name(TransportId::Wasm), "wasm");
626    }
627
628    #[test]
629    fn route_security_plan_clone_debug() {
630        let plan = RouteSecurityPlan {
631            access_mode: AccessMode::Authenticated,
632            provider_ref: Some("idp-a".to_string()),
633            transport: TransportId::Http,
634            credential_sources: vec![CredentialSource::AuthorizationHeader],
635            audience_binding: None,
636        };
637        let cloned = plan.clone();
638        assert_eq!(cloned.provider_ref, plan.provider_ref);
639        assert_eq!(cloned.transport, plan.transport);
640        assert_eq!(cloned.credential_sources, plan.credential_sources);
641        assert!(matches!(cloned.access_mode, AccessMode::Authenticated));
642        assert!(cloned.audience_binding.is_none());
643
644        let debug = format!("{plan:?}");
645        assert!(debug.contains(r#"provider_ref: Some("idp-a")"#));
646        assert!(!debug.contains("GrantPolicy"));
647    }
648}