Skip to main content

camel_auth/
permission_policy.rs

1//! Bridge between [`SecurityPolicy`] (Exchange-level) and [`PermissionEvaluator`] (permission-level).
2//!
3//! [`PermissionPolicy`] resolves resource and action from the exchange (literal, header, or property),
4//! builds an evaluation context from configured headers/properties, and delegates to a
5//! [`PermissionEvaluator`] implementation.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use camel_api::security_policy::{AuthContext, AuthorizationDecision, SecurityPolicy};
11use camel_api::{CamelError, Exchange};
12
13use crate::permission::{
14    PermissionContextConfig, PermissionDecision, PermissionEvaluator, PermissionRequest,
15    PermissionValueSource,
16};
17
18/// Where to read the resource/action label for error messages.
19trait LabelSource {
20    fn label(&self) -> String;
21}
22
23impl LabelSource for PermissionValueSource {
24    fn label(&self) -> String {
25        match self {
26            PermissionValueSource::Literal(s) => s.clone(),
27            PermissionValueSource::Header(name) => format!("header:{name}"),
28            PermissionValueSource::Property(name) => format!("property:{name}"),
29        }
30    }
31}
32
33/// SecurityPolicy implementation that delegates to a [`PermissionEvaluator`].
34///
35/// Resolves resource and action from the exchange, builds an evaluation context
36/// from configured headers and properties, and translates the evaluator's decision
37/// into an [`AuthorizationDecision`].
38pub struct PermissionPolicy {
39    evaluator: Arc<dyn PermissionEvaluator>,
40    resource: PermissionValueSource,
41    action: PermissionValueSource,
42    scopes: Vec<String>,
43    context: PermissionContextConfig,
44}
45
46impl PermissionPolicy {
47    pub fn new(
48        evaluator: Arc<dyn PermissionEvaluator>,
49        resource: PermissionValueSource,
50        action: PermissionValueSource,
51        scopes: Vec<String>,
52        context: PermissionContextConfig,
53    ) -> Self {
54        Self {
55            evaluator,
56            resource,
57            action,
58            scopes,
59            context,
60        }
61    }
62
63    fn resolve_source(source: &PermissionValueSource, exchange: &Exchange) -> Option<String> {
64        match source {
65            PermissionValueSource::Literal(s) => Some(s.clone()),
66            PermissionValueSource::Header(name) => exchange
67                .input
68                .header(name)
69                .and_then(|v| v.as_str())
70                .map(String::from),
71            PermissionValueSource::Property(name) => exchange
72                .property(name)
73                .and_then(|v| v.as_str())
74                .map(String::from),
75        }
76    }
77
78    fn build_context(&self, exchange: &Exchange) -> serde_json::Value {
79        let mut map = serde_json::Map::new();
80        for name in &self.context.include_headers {
81            if let Some(v) = exchange.input.header(name) {
82                map.insert(name.clone(), v.clone());
83            }
84        }
85        for name in &self.context.include_properties {
86            if let Some(v) = exchange.property(name) {
87                map.insert(name.clone(), v.clone());
88            }
89        }
90        serde_json::Value::Object(map)
91    }
92
93    fn resource_label(&self) -> String {
94        self.resource.label()
95    }
96
97    fn action_label(&self) -> String {
98        self.action.label()
99    }
100}
101
102#[async_trait]
103impl SecurityPolicy for PermissionPolicy {
104    async fn evaluate(
105        &self,
106        exchange: &mut Exchange,
107        auth: &AuthContext<'_>,
108    ) -> Result<AuthorizationDecision, CamelError> {
109        let principal = auth.principal.principal().clone();
110        let resource = Self::resolve_source(&self.resource, exchange)
111            .ok_or_else(|| CamelError::Unauthorized("cannot resolve permission resource".into()))?;
112        let action = Self::resolve_source(&self.action, exchange)
113            .ok_or_else(|| CamelError::Unauthorized("cannot resolve permission action".into()))?;
114        let context = self.build_context(exchange);
115        let request = PermissionRequest {
116            principal: principal.clone(),
117            resource,
118            action,
119            requested_scopes: self.scopes.clone(),
120            context,
121        };
122        match self.evaluator.evaluate(request).await {
123            Ok(PermissionDecision::Granted) => Ok(AuthorizationDecision::Granted { principal }),
124            Ok(PermissionDecision::Denied { reason }) => Ok(AuthorizationDecision::Denied {
125                reason,
126                required: vec![format!("{}:{}", self.resource_label(), self.action_label())],
127                actual: vec![],
128            }),
129            Err(e) => Err(e.into()),
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::permission::{
138        PermissionContextConfig, PermissionDecision, PermissionEvaluator, PermissionRequest,
139        PermissionValueSource,
140    };
141    use crate::types::AuthError;
142    use camel_api::Message;
143    use camel_api::security_policy::{AuthContext, AuthPrincipal, Principal, TransportId};
144    use serde_json::json;
145
146    fn test_principal() -> Principal {
147        Principal {
148            subject: "alice".into(),
149            issuer: "https://keycloak.example.com/realms/test".into(),
150            audience: vec!["camel-api".into()],
151            roles: vec!["admin".into()],
152            scopes: vec!["read".into()],
153            claims: json!({}),
154        }
155    }
156
157    struct TestPrincipal(Principal);
158
159    impl AuthPrincipal for TestPrincipal {
160        fn principal(&self) -> &Principal {
161            &self.0
162        }
163        fn provider_id(&self) -> &str {
164            "test"
165        }
166    }
167
168    fn auth_ctx<'a>(principal: &'a TestPrincipal) -> AuthContext<'a> {
169        AuthContext {
170            principal,
171            transport: TransportId::Http,
172        }
173    }
174
175    // --- Mock evaluators ---
176
177    struct GrantEvaluator;
178
179    #[async_trait]
180    impl PermissionEvaluator for GrantEvaluator {
181        async fn evaluate(
182            &self,
183            _request: PermissionRequest,
184        ) -> Result<PermissionDecision, AuthError> {
185            Ok(PermissionDecision::Granted)
186        }
187    }
188
189    struct DenyEvaluator {
190        reason: String,
191    }
192
193    #[async_trait]
194    impl PermissionEvaluator for DenyEvaluator {
195        async fn evaluate(
196            &self,
197            _request: PermissionRequest,
198        ) -> Result<PermissionDecision, AuthError> {
199            Ok(PermissionDecision::Denied {
200                reason: self.reason.clone(),
201            })
202        }
203    }
204
205    /// Evaluator that checks the resource field matches an expected value.
206    struct CheckEvaluator {
207        expected_resource: String,
208    }
209
210    #[async_trait]
211    impl PermissionEvaluator for CheckEvaluator {
212        async fn evaluate(
213            &self,
214            request: PermissionRequest,
215        ) -> Result<PermissionDecision, AuthError> {
216            if request.resource == self.expected_resource {
217                Ok(PermissionDecision::Granted)
218            } else {
219                Ok(PermissionDecision::Denied {
220                    reason: format!(
221                        "expected resource '{}', got '{}'",
222                        self.expected_resource, request.resource
223                    ),
224                })
225            }
226        }
227    }
228
229    /// Evaluator that asserts specific keys are present/absent in the context.
230    struct ContextCheckEvaluator {
231        must_have: String,
232        must_not_have: String,
233    }
234
235    #[async_trait]
236    impl PermissionEvaluator for ContextCheckEvaluator {
237        async fn evaluate(
238            &self,
239            request: PermissionRequest,
240        ) -> Result<PermissionDecision, AuthError> {
241            let ctx = request
242                .context
243                .as_object()
244                .expect("context should be an object");
245            if !ctx.contains_key(&self.must_have) {
246                return Ok(PermissionDecision::Denied {
247                    reason: format!("context missing required key '{}'", self.must_have),
248                });
249            }
250            if ctx.contains_key(&self.must_not_have) {
251                return Ok(PermissionDecision::Denied {
252                    reason: format!("context should not contain key '{}'", self.must_not_have),
253                });
254            }
255            Ok(PermissionDecision::Granted)
256        }
257    }
258
259    fn default_context_config() -> PermissionContextConfig {
260        PermissionContextConfig::default()
261    }
262
263    // --- Tests ---
264
265    #[tokio::test]
266    async fn grants_when_evaluator_grants() {
267        let principal = TestPrincipal(test_principal());
268        let policy = PermissionPolicy::new(
269            Arc::new(GrantEvaluator),
270            PermissionValueSource::Literal("/orders".into()),
271            PermissionValueSource::Literal("read".into()),
272            vec![],
273            default_context_config(),
274        );
275        let mut ex = Exchange::new(Message::default());
276        let auth = auth_ctx(&principal);
277        let decision = policy.evaluate(&mut ex, &auth).await.unwrap();
278        match decision {
279            AuthorizationDecision::Granted { principal: p } => {
280                assert_eq!(p.subject, "alice");
281            }
282            AuthorizationDecision::Denied { .. } => panic!("expected Granted, got Denied"),
283            _ => panic!("unexpected AuthorizationDecision variant"),
284        }
285    }
286
287    #[tokio::test]
288    async fn denies_when_evaluator_denies() {
289        let principal = TestPrincipal(test_principal());
290        let policy = PermissionPolicy::new(
291            Arc::new(DenyEvaluator {
292                reason: "insufficient scope".into(),
293            }),
294            PermissionValueSource::Literal("/orders".into()),
295            PermissionValueSource::Literal("write".into()),
296            vec![],
297            default_context_config(),
298        );
299        let mut ex = Exchange::new(Message::default());
300        let auth = auth_ctx(&principal);
301        let decision = policy.evaluate(&mut ex, &auth).await.unwrap();
302        match decision {
303            AuthorizationDecision::Denied { reason, .. } => {
304                assert_eq!(reason, "insufficient scope");
305            }
306            AuthorizationDecision::Granted { .. } => panic!("expected Denied, got Granted"),
307            _ => panic!("unexpected AuthorizationDecision variant"),
308        }
309    }
310
311    #[tokio::test]
312    async fn resolves_header_source() {
313        let principal = TestPrincipal(test_principal());
314        let policy = PermissionPolicy::new(
315            Arc::new(CheckEvaluator {
316                expected_resource: "res-from-header".into(),
317            }),
318            PermissionValueSource::Header("X-Resource".into()),
319            PermissionValueSource::Literal("read".into()),
320            vec![],
321            default_context_config(),
322        );
323        let mut ex = Exchange::new(Message::default());
324        ex.input.set_header("X-Resource", "res-from-header");
325        let auth = auth_ctx(&principal);
326        let decision = policy.evaluate(&mut ex, &auth).await.unwrap();
327        assert!(
328            matches!(decision, AuthorizationDecision::Granted { .. }),
329            "expected Granted, got {:?}",
330            decision
331        );
332    }
333
334    #[tokio::test]
335    async fn unauthorized_when_resource_cannot_be_resolved() {
336        let principal = TestPrincipal(test_principal());
337        let policy = PermissionPolicy::new(
338            Arc::new(GrantEvaluator),
339            PermissionValueSource::Header("X-Resource".into()),
340            PermissionValueSource::Literal("read".into()),
341            vec![],
342            default_context_config(),
343        );
344        let mut ex = Exchange::new(Message::default());
345        // X-Resource header NOT set → cannot resolve
346        let auth = auth_ctx(&principal);
347        let result = policy.evaluate(&mut ex, &auth).await;
348        assert!(
349            matches!(result, Err(CamelError::Unauthorized(ref msg)) if msg.contains("cannot resolve permission resource")),
350            "expected Unauthorized error for unresolved resource, got {:?}",
351            result
352        );
353    }
354
355    #[tokio::test]
356    async fn context_includes_only_configured_fields() {
357        let principal = TestPrincipal(test_principal());
358        let context_config = PermissionContextConfig {
359            include_headers: vec!["X-Tenant".into()],
360            include_properties: vec![],
361        };
362        let policy = PermissionPolicy::new(
363            Arc::new(ContextCheckEvaluator {
364                must_have: "X-Tenant".into(),
365                must_not_have: "X-Other".into(),
366            }),
367            PermissionValueSource::Literal("/orders".into()),
368            PermissionValueSource::Literal("read".into()),
369            vec![],
370            context_config,
371        );
372        let mut ex = Exchange::new(Message::default());
373        ex.input.set_header("X-Tenant", "acme");
374        ex.input.set_header("X-Other", "should-not-appear");
375        let auth = auth_ctx(&principal);
376        let decision = policy.evaluate(&mut ex, &auth).await.unwrap();
377        assert!(
378            matches!(decision, AuthorizationDecision::Granted { .. }),
379            "expected Granted, got {:?}",
380            decision
381        );
382    }
383}