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