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    Granted {
55        principal: Principal,
56    },
57    Denied {
58        reason: String,
59        required: Vec<String>,
60        actual: Vec<String>,
61    },
62}
63
64impl std::fmt::Display for AuthorizationDecision {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Granted { principal } => {
68                write!(f, "Access granted for {}", principal.subject)
69            }
70            Self::Denied { reason, .. } => write!(f, "Access denied: {reason}"),
71        }
72    }
73}
74
75#[async_trait]
76pub trait SecurityPolicy: Send + Sync {
77    async fn evaluate(&self, exchange: &mut Exchange) -> Result<AuthorizationDecision, CamelError>;
78}
79
80/// Name of the input header camel-http uses to carry the raw HTTP query string.
81///
82/// camel-http stores the request query (the part after `?`) as a string header
83/// under this exact name (`crates/components/camel-http/src/lib.rs`).
84/// camel-auth cannot depend on camel-http, so this contract constant lives in
85/// camel-api. `QueryParam` extraction reads the raw query from this header.
86pub const CAMEL_HTTP_QUERY_HEADER: &str = "CamelHttpQuery";
87
88/// Source from which a token can be extracted.
89///
90/// exhaustive-by-contract: closed source set; out-of-crate camel-auth
91/// extraction matches all variants, so adding a source must update every
92/// match site by review.
93#[derive(Clone, PartialEq, Eq)]
94pub enum CredentialSource {
95    /// Extract from the `Authorization` header (Bearer scheme).
96    AuthorizationHeader,
97    /// Extract from a query parameter with the given name.
98    QueryParam { param: String },
99    /// Extract from a cookie with the given name.
100    Cookie { name: String },
101    /// Extract from a named request header (API-key style).
102    ///
103    /// The extracted value flows into the same constant-time
104    /// `NativeCredentialStore::lookup` as every other source.
105    /// `ApiKeyAuthenticator` is superseded for YAML use; its programmatic API
106    /// stays.
107    Header { name: String },
108}
109
110impl CredentialSource {
111    /// Returns the variant name without exposing sensitive values.
112    pub fn variant_name(&self) -> &'static str {
113        match self {
114            CredentialSource::AuthorizationHeader => "AuthorizationHeader",
115            CredentialSource::QueryParam { .. } => "QueryParam",
116            CredentialSource::Cookie { .. } => "Cookie",
117            CredentialSource::Header { .. } => "Header",
118        }
119    }
120}
121
122impl std::fmt::Debug for CredentialSource {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            CredentialSource::AuthorizationHeader => f.write_str("AuthorizationHeader"),
126            CredentialSource::QueryParam { param } => {
127                write!(f, "QueryParam {{ param: {:?} }}", param) // allow-secret
128            }
129            CredentialSource::Cookie { name } => {
130                write!(f, "Cookie {{ name: {:?} }}", name) // allow-secret
131            }
132            CredentialSource::Header { name } => {
133                write!(f, "Header {{ name: {:?} }}", name) // allow-secret
134            }
135        }
136    }
137}
138
139pub struct SecurityPolicyConfig {
140    pub policy: Arc<dyn SecurityPolicy>,
141    /// Extraction sources for the route's credential, in declared order.
142    /// Defaults to header-only (fail-closed, ADR-0033).
143    pub credential_sources: Vec<CredentialSource>,
144}
145
146impl SecurityPolicyConfig {
147    pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
148        Self {
149            policy: Arc::new(policy),
150            credential_sources: vec![CredentialSource::AuthorizationHeader],
151        }
152    }
153
154    pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
155        Self {
156            policy,
157            credential_sources: vec![CredentialSource::AuthorizationHeader],
158        }
159    }
160
161    pub fn with_credential_sources(mut self, sources: Vec<CredentialSource>) -> Self {
162        self.credential_sources = sources;
163        self
164    }
165}
166
167impl Clone for SecurityPolicyConfig {
168    fn clone(&self) -> Self {
169        Self {
170            policy: Arc::clone(&self.policy),
171            credential_sources: self.credential_sources.clone(),
172        }
173    }
174}
175
176impl std::fmt::Debug for SecurityPolicyConfig {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("SecurityPolicyConfig")
179            .field("policy", &"<SecurityPolicy>")
180            .field("credential_sources", &self.credential_sources)
181            .finish()
182    }
183}
184
185// --- Principal property storage helpers ---
186
187/// Exchange property key for the principal's subject.
188pub const PRINCIPAL_SUBJECT_KEY: &str = "camel.auth.subject";
189/// Exchange property key for the principal's roles (JSON array).
190pub const PRINCIPAL_ROLES_KEY: &str = "camel.auth.roles";
191/// Exchange property key for the principal's scopes (JSON array).
192pub const PRINCIPAL_SCOPES_KEY: &str = "camel.auth.scopes";
193/// Exchange property key for the principal's issuer.
194pub const PRINCIPAL_ISSUER_KEY: &str = "camel.auth.issuer";
195/// Exchange property key for the principal's raw claims (JSON object).
196pub const PRINCIPAL_CLAIMS_KEY: &str = "camel.auth.claims";
197/// Exchange property key for the principal's audience (JSON array).
198pub const PRINCIPAL_AUDIENCE_KEY: &str = "camel.auth.audience";
199/// Exchange property key for the full serialized principal.
200pub const PRINCIPAL_KEY: &str = "camel.auth.principal";
201
202/// Store all principal properties as exchange properties under well-known keys.
203pub fn store_principal_properties(exchange: &mut Exchange, principal: &Principal) {
204    exchange.set_property(PRINCIPAL_SUBJECT_KEY, principal.subject.clone());
205    exchange.set_property(
206        PRINCIPAL_ROLES_KEY,
207        serde_json::to_string(&principal.roles).unwrap_or_default(),
208    );
209    exchange.set_property(
210        PRINCIPAL_SCOPES_KEY,
211        serde_json::to_string(&principal.scopes).unwrap_or_default(),
212    );
213    exchange.set_property(PRINCIPAL_ISSUER_KEY, principal.issuer.clone());
214    exchange.set_property(
215        PRINCIPAL_CLAIMS_KEY,
216        serde_json::to_string(&principal.claims).unwrap_or_default(),
217    );
218    exchange.set_property(
219        PRINCIPAL_AUDIENCE_KEY,
220        serde_json::to_string(&principal.audience).unwrap_or_default(),
221    );
222    exchange.set_property(
223        PRINCIPAL_KEY,
224        serde_json::to_string(principal).unwrap_or_default(),
225    );
226}
227
228pub fn principal_from_exchange(exchange: &Exchange) -> Option<Principal> {
229    exchange
230        .property(PRINCIPAL_KEY)
231        .and_then(|v| v.as_str())
232        .and_then(|s| serde_json::from_str(s).ok())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::Body;
239
240    fn test_principal(roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
241        Principal {
242            subject: "user1".into(),
243            issuer: "test".into(),
244            audience: vec![],
245            scopes: scopes.into_iter().map(String::from).collect(),
246            roles: roles.into_iter().map(String::from).collect(),
247            claims: serde_json::Value::Null,
248        }
249    }
250
251    /// A `SecurityPolicy` that always grants to `test_principal(vec![], vec![])`.
252    fn test_policy() -> impl SecurityPolicy + 'static {
253        struct GrantPolicy;
254
255        #[async_trait]
256        impl SecurityPolicy for GrantPolicy {
257            async fn evaluate(
258                &self,
259                _exchange: &mut Exchange,
260            ) -> Result<AuthorizationDecision, CamelError> {
261                Ok(AuthorizationDecision::Granted {
262                    principal: test_principal(vec![], vec![]),
263                })
264            }
265        }
266
267        GrantPolicy
268    }
269
270    #[test]
271    fn principal_has_role_is_case_sensitive() {
272        let p = test_principal(vec!["Admin", "User"], vec![]);
273        assert!(!p.has_role("admin"));
274        assert!(!p.has_role("ADMIN"));
275        assert!(p.has_role("User"));
276        assert!(!p.has_role("guest"));
277    }
278
279    #[test]
280    fn principal_has_scope() {
281        let p = test_principal(vec![], vec!["read", "write"]);
282        assert!(p.has_scope("read"));
283        assert!(!p.has_scope("delete"));
284    }
285
286    #[test]
287    fn authorization_decision_granted_display() {
288        let p = test_principal(vec![], vec![]);
289        let d = AuthorizationDecision::Granted { principal: p };
290        assert!(format!("{d}").contains("user1"));
291    }
292
293    #[test]
294    fn authorization_decision_denied_display() {
295        let d = AuthorizationDecision::Denied {
296            reason: "missing role".into(),
297            required: vec!["admin".into()],
298            actual: vec![],
299        };
300        assert!(format!("{d}").contains("missing role"));
301    }
302
303    #[test]
304    fn security_policy_config_debug_redacts_policy() {
305        let config = SecurityPolicyConfig::new(test_policy());
306        let debug = format!("{config:?}");
307        assert!(debug.contains("SecurityPolicyConfig"));
308        assert!(debug.contains("<SecurityPolicy>"));
309    }
310
311    #[test]
312    fn security_policy_config_new_is_header_only() {
313        let config = SecurityPolicyConfig::new(test_policy());
314        assert_eq!(
315            config.credential_sources,
316            vec![CredentialSource::AuthorizationHeader]
317        );
318    }
319
320    #[test]
321    fn store_principal_properties_populates_all_keys() {
322        let principal = Principal {
323            subject: "alice".into(),
324            issuer: "keycloak".into(),
325            audience: vec!["api".into()],
326            scopes: vec!["read".into(), "write".into()],
327            roles: vec!["admin".into()],
328            claims: serde_json::json!({"sub": "alice", "custom": true}),
329        };
330        let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
331        store_principal_properties(&mut exchange, &principal);
332
333        assert_eq!(
334            exchange.property(PRINCIPAL_SUBJECT_KEY).unwrap(),
335            &serde_json::Value::String("alice".into())
336        );
337        assert_eq!(
338            exchange.property(PRINCIPAL_ISSUER_KEY).unwrap(),
339            &serde_json::Value::String("keycloak".into())
340        );
341        let roles: Vec<String> = serde_json::from_str(
342            exchange
343                .property(PRINCIPAL_ROLES_KEY)
344                .unwrap()
345                .as_str()
346                .unwrap(),
347        )
348        .unwrap();
349        assert_eq!(roles, vec!["admin"]);
350        let scopes: Vec<String> = serde_json::from_str(
351            exchange
352                .property(PRINCIPAL_SCOPES_KEY)
353                .unwrap()
354                .as_str()
355                .unwrap(),
356        )
357        .unwrap();
358        assert_eq!(scopes, vec!["read", "write"]);
359        let audience: Vec<String> = serde_json::from_str(
360            exchange
361                .property(PRINCIPAL_AUDIENCE_KEY)
362                .unwrap()
363                .as_str()
364                .unwrap(),
365        )
366        .unwrap();
367        assert_eq!(audience, vec!["api"]);
368        let claims: serde_json::Value = serde_json::from_str(
369            exchange
370                .property(PRINCIPAL_CLAIMS_KEY)
371                .unwrap()
372                .as_str()
373                .unwrap(),
374        )
375        .unwrap();
376        assert!(claims.as_object().unwrap().contains_key("custom"));
377        let full: serde_json::Value =
378            serde_json::from_str(exchange.property(PRINCIPAL_KEY).unwrap().as_str().unwrap())
379                .unwrap();
380        assert_eq!(full["subject"], "alice");
381    }
382
383    #[test]
384    fn security_policy_config_clone() {
385        let config = SecurityPolicyConfig::new(test_policy());
386        let cloned = config.clone();
387        // Both point to same Arc
388        assert!(Arc::ptr_eq(&config.policy, &cloned.policy));
389    }
390
391    #[test]
392    fn test_principal_from_exchange_round_trip() {
393        let principal = Principal {
394            subject: "bob".into(),
395            issuer: "keycloak".into(),
396            audience: vec!["api".into()],
397            scopes: vec!["read".into()],
398            roles: vec!["user".into()],
399            claims: serde_json::json!({"sub": "bob"}),
400        };
401        let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
402        store_principal_properties(&mut exchange, &principal);
403
404        let recovered = principal_from_exchange(&exchange).expect("principal should be recovered");
405        assert_eq!(recovered.subject, "bob");
406        assert_eq!(recovered.issuer, "keycloak");
407        assert_eq!(recovered.audience, vec!["api"]);
408        assert_eq!(recovered.scopes, vec!["read"]);
409        assert_eq!(recovered.roles, vec!["user"]);
410    }
411
412    #[test]
413    fn principal_debug_redacts_claims_compact() {
414        let principal = Principal {
415            subject: "subj-1".into(),
416            issuer: "iss".into(),
417            audience: vec!["a1".into()],
418            scopes: vec!["s1".into()],
419            roles: vec!["r1".into()],
420            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
421        };
422        let s = format!("{principal:?}");
423        assert!(
424            s.contains("claims: \"[REDACTED]\""),
425            "compact debug should show [REDACTED] for claims"
426        );
427        assert!(
428            !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
429            "compact debug should NOT contain raw claim value"
430        );
431        assert!(s.contains("subj-1"), "compact debug should contain subject");
432        assert!(s.contains("iss"), "compact debug should contain issuer");
433        assert!(s.contains("a1"), "compact debug should contain audience");
434        assert!(s.contains("s1"), "compact debug should contain scopes");
435        assert!(s.contains("r1"), "compact debug should contain roles");
436    }
437
438    #[test]
439    fn principal_debug_redacts_claims_pretty() {
440        let principal = Principal {
441            subject: "subj-1".into(),
442            issuer: "iss".into(),
443            audience: vec!["a1".into()],
444            scopes: vec!["s1".into()],
445            roles: vec!["r1".into()],
446            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
447        };
448        let s = format!("{principal:#?}");
449        assert!(
450            s.contains("[REDACTED]"),
451            "pretty debug should show [REDACTED]"
452        );
453        assert!(
454            !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
455            "pretty debug should NOT contain raw claim value"
456        );
457    }
458
459    #[test]
460    fn principal_serialize_preserves_claims() {
461        let principal = Principal {
462            subject: "subj-1".into(),
463            issuer: "iss".into(),
464            audience: vec!["a1".into()],
465            scopes: vec!["s1".into()],
466            roles: vec!["r1".into()],
467            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
468        };
469        let s = serde_json::to_string(&principal).unwrap();
470        assert!(
471            s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
472            "serialization should preserve raw claim value"
473        );
474    }
475}