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
80pub struct SecurityPolicyConfig {
81    pub policy: Arc<dyn SecurityPolicy>,
82}
83
84impl SecurityPolicyConfig {
85    pub fn new(policy: impl SecurityPolicy + 'static) -> Self {
86        Self {
87            policy: Arc::new(policy),
88        }
89    }
90
91    pub fn from_arc(policy: Arc<dyn SecurityPolicy>) -> Self {
92        Self { policy }
93    }
94}
95
96impl Clone for SecurityPolicyConfig {
97    fn clone(&self) -> Self {
98        Self {
99            policy: Arc::clone(&self.policy),
100        }
101    }
102}
103
104impl std::fmt::Debug for SecurityPolicyConfig {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("SecurityPolicyConfig")
107            .field("policy", &"<SecurityPolicy>")
108            .finish()
109    }
110}
111
112// --- Principal property storage helpers ---
113
114/// Exchange property key for the principal's subject.
115pub const PRINCIPAL_SUBJECT_KEY: &str = "camel.auth.subject";
116/// Exchange property key for the principal's roles (JSON array).
117pub const PRINCIPAL_ROLES_KEY: &str = "camel.auth.roles";
118/// Exchange property key for the principal's scopes (JSON array).
119pub const PRINCIPAL_SCOPES_KEY: &str = "camel.auth.scopes";
120/// Exchange property key for the principal's issuer.
121pub const PRINCIPAL_ISSUER_KEY: &str = "camel.auth.issuer";
122/// Exchange property key for the principal's raw claims (JSON object).
123pub const PRINCIPAL_CLAIMS_KEY: &str = "camel.auth.claims";
124/// Exchange property key for the principal's audience (JSON array).
125pub const PRINCIPAL_AUDIENCE_KEY: &str = "camel.auth.audience";
126/// Exchange property key for the full serialized principal.
127pub const PRINCIPAL_KEY: &str = "camel.auth.principal";
128
129/// Store all principal properties as exchange properties under well-known keys.
130pub fn store_principal_properties(exchange: &mut Exchange, principal: &Principal) {
131    exchange.set_property(PRINCIPAL_SUBJECT_KEY, principal.subject.clone());
132    exchange.set_property(
133        PRINCIPAL_ROLES_KEY,
134        serde_json::to_string(&principal.roles).unwrap_or_default(),
135    );
136    exchange.set_property(
137        PRINCIPAL_SCOPES_KEY,
138        serde_json::to_string(&principal.scopes).unwrap_or_default(),
139    );
140    exchange.set_property(PRINCIPAL_ISSUER_KEY, principal.issuer.clone());
141    exchange.set_property(
142        PRINCIPAL_CLAIMS_KEY,
143        serde_json::to_string(&principal.claims).unwrap_or_default(),
144    );
145    exchange.set_property(
146        PRINCIPAL_AUDIENCE_KEY,
147        serde_json::to_string(&principal.audience).unwrap_or_default(),
148    );
149    exchange.set_property(
150        PRINCIPAL_KEY,
151        serde_json::to_string(principal).unwrap_or_default(),
152    );
153}
154
155pub fn principal_from_exchange(exchange: &Exchange) -> Option<Principal> {
156    exchange
157        .property(PRINCIPAL_KEY)
158        .and_then(|v| v.as_str())
159        .and_then(|s| serde_json::from_str(s).ok())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::Body;
166
167    fn test_principal(roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
168        Principal {
169            subject: "user1".into(),
170            issuer: "test".into(),
171            audience: vec![],
172            scopes: scopes.into_iter().map(String::from).collect(),
173            roles: roles.into_iter().map(String::from).collect(),
174            claims: serde_json::Value::Null,
175        }
176    }
177
178    #[test]
179    fn principal_has_role_is_case_sensitive() {
180        let p = test_principal(vec!["Admin", "User"], vec![]);
181        assert!(!p.has_role("admin"));
182        assert!(!p.has_role("ADMIN"));
183        assert!(p.has_role("User"));
184        assert!(!p.has_role("guest"));
185    }
186
187    #[test]
188    fn principal_has_scope() {
189        let p = test_principal(vec![], vec!["read", "write"]);
190        assert!(p.has_scope("read"));
191        assert!(!p.has_scope("delete"));
192    }
193
194    #[test]
195    fn authorization_decision_granted_display() {
196        let p = test_principal(vec![], vec![]);
197        let d = AuthorizationDecision::Granted { principal: p };
198        assert!(format!("{d}").contains("user1"));
199    }
200
201    #[test]
202    fn authorization_decision_denied_display() {
203        let d = AuthorizationDecision::Denied {
204            reason: "missing role".into(),
205            required: vec!["admin".into()],
206            actual: vec![],
207        };
208        assert!(format!("{d}").contains("missing role"));
209    }
210
211    #[test]
212    fn security_policy_config_debug_redacts_policy() {
213        struct DummyPolicy;
214
215        #[async_trait]
216        impl SecurityPolicy for DummyPolicy {
217            async fn evaluate(
218                &self,
219                _exchange: &mut Exchange,
220            ) -> Result<AuthorizationDecision, CamelError> {
221                Ok(AuthorizationDecision::Granted {
222                    principal: test_principal(vec![], vec![]),
223                })
224            }
225        }
226
227        let config = SecurityPolicyConfig::new(DummyPolicy);
228        let debug = format!("{config:?}");
229        assert!(debug.contains("SecurityPolicyConfig"));
230        assert!(debug.contains("<SecurityPolicy>"));
231    }
232
233    #[test]
234    fn store_principal_properties_populates_all_keys() {
235        let principal = Principal {
236            subject: "alice".into(),
237            issuer: "keycloak".into(),
238            audience: vec!["api".into()],
239            scopes: vec!["read".into(), "write".into()],
240            roles: vec!["admin".into()],
241            claims: serde_json::json!({"sub": "alice", "custom": true}),
242        };
243        let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
244        store_principal_properties(&mut exchange, &principal);
245
246        assert_eq!(
247            exchange.property(PRINCIPAL_SUBJECT_KEY).unwrap(),
248            &serde_json::Value::String("alice".into())
249        );
250        assert_eq!(
251            exchange.property(PRINCIPAL_ISSUER_KEY).unwrap(),
252            &serde_json::Value::String("keycloak".into())
253        );
254        let roles: Vec<String> = serde_json::from_str(
255            exchange
256                .property(PRINCIPAL_ROLES_KEY)
257                .unwrap()
258                .as_str()
259                .unwrap(),
260        )
261        .unwrap();
262        assert_eq!(roles, vec!["admin"]);
263        let scopes: Vec<String> = serde_json::from_str(
264            exchange
265                .property(PRINCIPAL_SCOPES_KEY)
266                .unwrap()
267                .as_str()
268                .unwrap(),
269        )
270        .unwrap();
271        assert_eq!(scopes, vec!["read", "write"]);
272        let audience: Vec<String> = serde_json::from_str(
273            exchange
274                .property(PRINCIPAL_AUDIENCE_KEY)
275                .unwrap()
276                .as_str()
277                .unwrap(),
278        )
279        .unwrap();
280        assert_eq!(audience, vec!["api"]);
281        let claims: serde_json::Value = serde_json::from_str(
282            exchange
283                .property(PRINCIPAL_CLAIMS_KEY)
284                .unwrap()
285                .as_str()
286                .unwrap(),
287        )
288        .unwrap();
289        assert!(claims.as_object().unwrap().contains_key("custom"));
290        let full: serde_json::Value =
291            serde_json::from_str(exchange.property(PRINCIPAL_KEY).unwrap().as_str().unwrap())
292                .unwrap();
293        assert_eq!(full["subject"], "alice");
294    }
295
296    #[test]
297    fn security_policy_config_clone() {
298        struct DummyPolicy;
299
300        #[async_trait]
301        impl SecurityPolicy for DummyPolicy {
302            async fn evaluate(
303                &self,
304                _exchange: &mut Exchange,
305            ) -> Result<AuthorizationDecision, CamelError> {
306                Ok(AuthorizationDecision::Granted {
307                    principal: test_principal(vec![], vec![]),
308                })
309            }
310        }
311
312        let config = SecurityPolicyConfig::new(DummyPolicy);
313        let cloned = config.clone();
314        // Both point to same Arc
315        assert!(Arc::ptr_eq(&config.policy, &cloned.policy));
316    }
317
318    #[test]
319    fn test_principal_from_exchange_round_trip() {
320        let principal = Principal {
321            subject: "bob".into(),
322            issuer: "keycloak".into(),
323            audience: vec!["api".into()],
324            scopes: vec!["read".into()],
325            roles: vec!["user".into()],
326            claims: serde_json::json!({"sub": "bob"}),
327        };
328        let mut exchange = Exchange::new(crate::Message::new(Body::Empty));
329        store_principal_properties(&mut exchange, &principal);
330
331        let recovered = principal_from_exchange(&exchange).expect("principal should be recovered");
332        assert_eq!(recovered.subject, "bob");
333        assert_eq!(recovered.issuer, "keycloak");
334        assert_eq!(recovered.audience, vec!["api"]);
335        assert_eq!(recovered.scopes, vec!["read"]);
336        assert_eq!(recovered.roles, vec!["user"]);
337    }
338
339    #[test]
340    fn principal_debug_redacts_claims_compact() {
341        let principal = Principal {
342            subject: "subj-1".into(),
343            issuer: "iss".into(),
344            audience: vec!["a1".into()],
345            scopes: vec!["s1".into()],
346            roles: vec!["r1".into()],
347            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
348        };
349        let s = format!("{principal:?}");
350        assert!(
351            s.contains("claims: \"[REDACTED]\""),
352            "compact debug should show [REDACTED] for claims"
353        );
354        assert!(
355            !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
356            "compact debug should NOT contain raw claim value"
357        );
358        assert!(s.contains("subj-1"), "compact debug should contain subject");
359        assert!(s.contains("iss"), "compact debug should contain issuer");
360        assert!(s.contains("a1"), "compact debug should contain audience");
361        assert!(s.contains("s1"), "compact debug should contain scopes");
362        assert!(s.contains("r1"), "compact debug should contain roles");
363    }
364
365    #[test]
366    fn principal_debug_redacts_claims_pretty() {
367        let principal = Principal {
368            subject: "subj-1".into(),
369            issuer: "iss".into(),
370            audience: vec!["a1".into()],
371            scopes: vec!["s1".into()],
372            roles: vec!["r1".into()],
373            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
374        };
375        let s = format!("{principal:#?}");
376        assert!(
377            s.contains("[REDACTED]"),
378            "pretty debug should show [REDACTED]"
379        );
380        assert!(
381            !s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
382            "pretty debug should NOT contain raw claim value"
383        );
384    }
385
386    #[test]
387    fn principal_serialize_preserves_claims() {
388        let principal = Principal {
389            subject: "subj-1".into(),
390            issuer: "iss".into(),
391            audience: vec!["a1".into()],
392            scopes: vec!["s1".into()],
393            roles: vec!["r1".into()],
394            claims: serde_json::json!({"piid": "SENTINEL_CLAIM_VALUE_9kq2"}),
395        };
396        let s = serde_json::to_string(&principal).unwrap();
397        assert!(
398            s.contains("SENTINEL_CLAIM_VALUE_9kq2"),
399            "serialization should preserve raw claim value"
400        );
401    }
402}