Skip to main content

mcp_proxy/
rbac.rs

1//! Role-based access control (RBAC) middleware for the proxy.
2//!
3//! This module enforces per-role tool access policies on authenticated requests.
4//! It reads JWT claims from [`RouterRequest`] extensions, maps claim values to
5//! named roles via a configurable mapping, and applies per-role allow/deny lists
6//! to both `tools/call` and `tools/list` requests.
7//!
8//! # How role resolution works
9//!
10//! 1. The auth layer (JWT or introspection) validates the token and inserts
11//!    [`TokenClaims`](tower_mcp::oauth::token::TokenClaims) into the request
12//!    extensions.
13//! 2. [`RbacConfig`] reads a configured claim (e.g. `"scope"`, `"role"`,
14//!    `"groups"`) from those claims.
15//! 3. The claim value is matched against `role_mapping.mapping` to resolve a
16//!    role name (e.g. `"read-only"` -> `"reader"`).
17//! 4. The resolved role's `allow_tools` and `deny_tools` lists determine access.
18//!
19//! If no [`TokenClaims`](tower_mcp::oauth::token::TokenClaims) are present in
20//! the request extensions (e.g. unauthenticated or bearer-token-only requests),
21//! the RBAC layer passes the request through without restriction.
22//!
23//! # Allow/deny list semantics
24//!
25//! Each role can define an allow list, a deny list, or both:
26//!
27//! - **Allow list only**: only the listed tools are accessible. All others are
28//!   denied.
29//! - **Deny list only**: all tools are accessible except those listed.
30//! - **Both**: a tool must appear in the allow list AND not appear in the deny
31//!   list.
32//! - **Neither** (empty lists): the role has unrestricted access (e.g. an admin
33//!   role).
34//!
35//! # Interaction with capability filtering
36//!
37//! RBAC runs **on top of** the static capability filter configured per backend.
38//! The final set of visible tools is the **intersection** of what the backend
39//! exposes and what the role permits -- RBAC can only further restrict, never
40//! widen, the tools a client can see or call.
41//!
42//! # Configuration example
43//!
44//! ```toml
45//! [auth]
46//! type = "jwt"
47//! issuer = "https://auth.example.com"
48//! audience = "mcp-proxy"
49//! jwks_uri = "https://auth.example.com/.well-known/jwks.json"
50//!
51//! [[auth.roles]]
52//! name = "admin"
53//! # Empty allow/deny = unrestricted access
54//!
55//! [[auth.roles]]
56//! name = "reader"
57//! allow_tools = ["files/read_file", "files/list_dir"]
58//!
59//! [[auth.roles]]
60//! name = "developer"
61//! deny_tools = ["admin/restart", "admin/shutdown"]
62//!
63//! [auth.role_mapping]
64//! claim = "scope"
65//!
66//! [auth.role_mapping.mapping]
67//! admin = "admin"
68//! read-only = "reader"
69//! dev = "developer"
70//! ```
71//!
72//! # Enforcement
73//!
74//! [`RbacService`] is a Tower middleware that wraps the proxy's inner service.
75//! On `tools/call` requests, it checks the tool name against the resolved role
76//! before forwarding. On `tools/list` responses, it filters out tools the role
77//! cannot access. Denied calls receive a JSON-RPC `InvalidParams` error with
78//! a message identifying the role and tool.
79
80use std::collections::{HashMap, HashSet};
81use std::convert::Infallible;
82use std::future::Future;
83use std::pin::Pin;
84use std::sync::Arc;
85use std::task::{Context, Poll};
86
87use tower::Service;
88
89use tower_mcp::protocol::{McpRequest, McpResponse};
90use tower_mcp::{RouterRequest, RouterResponse};
91use tower_mcp_types::JsonRpcError;
92
93use crate::config::{RoleConfig, RoleMappingConfig};
94
95/// Outcome of resolving a role from request token claims.
96enum RoleResolution {
97    /// No `TokenClaims` present in the request (e.g. unauthenticated or
98    /// bearer-token-only requests). Always passes through.
99    NoClaims,
100    /// Claims present and the mapped claim value resolved to a named role.
101    Role(String),
102    /// Claims present, but the claim value is not in `claim_to_role`. Governed
103    /// by `default_deny`.
104    Unmapped,
105}
106
107/// Resolved RBAC rules.
108#[derive(Clone)]
109pub struct RbacConfig {
110    /// Claim name to read from TokenClaims (e.g. "scope", "role")
111    claim: String,
112    /// Map of claim value -> role name
113    claim_to_role: HashMap<String, String>,
114    /// Map of role name -> allowed tools (empty = all allowed)
115    role_allow: HashMap<String, HashSet<String>>,
116    /// Map of role name -> denied tools
117    role_deny: HashMap<String, HashSet<String>>,
118    /// Deny authenticated requests whose claim value is not in `claim_to_role`.
119    default_deny: bool,
120}
121
122impl RbacConfig {
123    /// Build RBAC config from role definitions and claim-to-role mapping.
124    pub fn new(roles: &[RoleConfig], mapping: &RoleMappingConfig) -> Self {
125        let mut role_allow = HashMap::new();
126        let mut role_deny = HashMap::new();
127
128        for role in roles {
129            if !role.allow_tools.is_empty() {
130                role_allow.insert(
131                    role.name.clone(),
132                    role.allow_tools.iter().cloned().collect(),
133                );
134            }
135            if !role.deny_tools.is_empty() {
136                role_deny.insert(role.name.clone(), role.deny_tools.iter().cloned().collect());
137            }
138        }
139
140        Self {
141            claim: mapping.claim.clone(),
142            claim_to_role: mapping.mapping.clone(),
143            role_allow,
144            role_deny,
145            default_deny: mapping.default_deny,
146        }
147    }
148
149    /// Resolve the role for the current request from TokenClaims.
150    ///
151    /// Distinguishes three cases: no claims present (pass through), claims that
152    /// map to a named role, and claims whose value is unrecognized (governed by
153    /// `default_deny`).
154    fn resolve_role(&self, extensions: &tower_mcp::router::Extensions) -> RoleResolution {
155        let Some(claims) = extensions.get::<tower_mcp::oauth::token::TokenClaims>() else {
156            return RoleResolution::NoClaims;
157        };
158
159        // Check standard scope field first
160        if self.claim == "scope" {
161            let scopes = claims.scopes();
162            for scope in &scopes {
163                if let Some(role) = self.claim_to_role.get(scope) {
164                    return RoleResolution::Role(role.clone());
165                }
166            }
167            return RoleResolution::Unmapped;
168        }
169
170        // Check extra claims
171        if let Some(value) = claims.extra.get(&self.claim) {
172            let claim_str = match value {
173                serde_json::Value::String(s) => s.clone(),
174                other => other.to_string(),
175            };
176            // Try direct mapping
177            if let Some(role) = self.claim_to_role.get(&claim_str) {
178                return RoleResolution::Role(role.clone());
179            }
180            // Try space-delimited (like scope)
181            for part in claim_str.split_whitespace() {
182                if let Some(role) = self.claim_to_role.get(part) {
183                    return RoleResolution::Role(role.clone());
184                }
185            }
186        }
187
188        RoleResolution::Unmapped
189    }
190
191    /// Check if a tool is allowed for the given role.
192    fn is_tool_allowed(&self, role: &str, tool_name: &str) -> bool {
193        // If role has an allowlist, tool must be in it
194        if let Some(allowed) = self.role_allow.get(role)
195            && !allowed.contains(tool_name)
196        {
197            return false;
198        }
199        // If role has a denylist, tool must not be in it
200        if let Some(denied) = self.role_deny.get(role)
201            && denied.contains(tool_name)
202        {
203            return false;
204        }
205        true
206    }
207}
208
209/// Middleware that enforces RBAC on tool calls and list responses.
210#[derive(Clone)]
211pub struct RbacService<S> {
212    inner: S,
213    config: Arc<RbacConfig>,
214}
215
216impl<S> RbacService<S> {
217    /// Create a new RBAC enforcement service wrapping `inner`.
218    pub fn new(inner: S, config: RbacConfig) -> Self {
219        Self {
220            inner,
221            config: Arc::new(config),
222        }
223    }
224}
225
226impl<S> Service<RouterRequest> for RbacService<S>
227where
228    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
229        + Clone
230        + Send
231        + 'static,
232    S::Future: Send,
233{
234    type Response = RouterResponse;
235    type Error = Infallible;
236    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
237
238    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
239        self.inner.poll_ready(cx)
240    }
241
242    fn call(&mut self, req: RouterRequest) -> Self::Future {
243        let config = Arc::clone(&self.config);
244        let request_id = req.id.clone();
245
246        // Resolve role from extensions.
247        let role = match config.resolve_role(&req.extensions) {
248            // No TokenClaims at all (unauthenticated or bearer-token-only
249            // requests, already validated by the auth layer): pass through, no
250            // RBAC restriction applies.
251            RoleResolution::NoClaims => {
252                let fut = self.inner.call(req);
253                return Box::pin(fut);
254            }
255            // Valid claims whose scope is not in the mapping. Under default-deny
256            // this is rejected; otherwise it preserves the legacy pass-through.
257            RoleResolution::Unmapped => {
258                if config.default_deny {
259                    return Box::pin(async move {
260                        Ok(RouterResponse {
261                            id: request_id,
262                            inner: Err(JsonRpcError::invalid_params(
263                                "Authenticated principal carries no recognized role; \
264                                 access denied (rbac default_deny)"
265                                    .to_string(),
266                            )),
267                        })
268                    });
269                }
270                let fut = self.inner.call(req);
271                return Box::pin(fut);
272            }
273            RoleResolution::Role(role) => role,
274        };
275
276        let role_for_filter = role.clone();
277
278        // Check tool calls against RBAC
279        if let McpRequest::CallTool(ref params) = req.inner
280            && !config.is_tool_allowed(&role, &params.name)
281        {
282            let tool_name = params.name.clone();
283            return Box::pin(async move {
284                Ok(RouterResponse {
285                    id: request_id,
286                    inner: Err(JsonRpcError::invalid_params(format!(
287                        "Role '{}' is not authorized to call tool: {}",
288                        role, tool_name
289                    ))),
290                })
291            });
292        }
293
294        let fut = self.inner.call(req);
295
296        Box::pin(async move {
297            let mut resp = fut.await?;
298
299            // Filter list_tools response based on role
300            if let Ok(McpResponse::ListTools(ref mut result)) = resp.inner {
301                result
302                    .tools
303                    .retain(|tool| config.is_tool_allowed(&role_for_filter, &tool.name));
304            }
305
306            Ok(resp)
307        })
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use std::collections::HashMap;
314
315    use tower::Service;
316    use tower_mcp::oauth::token::TokenClaims;
317    use tower_mcp::protocol::{McpRequest, McpResponse, RequestId};
318    use tower_mcp::router::Extensions;
319
320    use super::{RbacConfig, RbacService};
321    use crate::config::{RoleConfig, RoleMappingConfig};
322    use crate::test_util::MockService;
323
324    fn test_rbac_config() -> RbacConfig {
325        rbac_config_with_default_deny(false)
326    }
327
328    fn rbac_config_with_default_deny(default_deny: bool) -> RbacConfig {
329        let roles = vec![
330            RoleConfig {
331                name: "admin".into(),
332                allow_tools: vec![],
333                deny_tools: vec![],
334            },
335            RoleConfig {
336                name: "reader".into(),
337                allow_tools: vec!["fs/read".into()],
338                deny_tools: vec![],
339            },
340        ];
341        let mapping = RoleMappingConfig {
342            claim: "scope".into(),
343            mapping: HashMap::from([
344                ("admin".into(), "admin".into()),
345                ("read-only".into(), "reader".into()),
346            ]),
347            default_deny,
348        };
349        RbacConfig::new(&roles, &mapping)
350    }
351
352    fn request_with_scope(scope: &str, inner: McpRequest) -> tower_mcp::RouterRequest {
353        let mut extensions = Extensions::new();
354        extensions.insert(TokenClaims {
355            sub: None,
356            iss: None,
357            aud: None,
358            exp: None,
359            scope: Some(scope.to_string()),
360            client_id: None,
361            extra: HashMap::new(),
362        });
363        tower_mcp::RouterRequest {
364            id: RequestId::Number(1),
365            inner,
366            extensions,
367        }
368    }
369
370    #[tokio::test]
371    async fn test_rbac_admin_can_call_any_tool() {
372        let mock = MockService::with_tools(&["fs/read", "fs/write"]);
373        let mut svc = RbacService::new(mock, test_rbac_config());
374
375        let req = request_with_scope(
376            "admin",
377            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
378                name: "fs/write".to_string(),
379                arguments: serde_json::json!({}),
380                input_responses: None,
381                request_state: None,
382                meta: None,
383                task: None,
384            }),
385        );
386        let resp = svc.call(req).await.unwrap();
387        assert!(resp.inner.is_ok(), "admin should call any tool");
388    }
389
390    #[tokio::test]
391    async fn test_rbac_reader_denied_write() {
392        let mock = MockService::with_tools(&["fs/read", "fs/write"]);
393        let mut svc = RbacService::new(mock, test_rbac_config());
394
395        let req = request_with_scope(
396            "read-only",
397            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
398                name: "fs/write".to_string(),
399                arguments: serde_json::json!({}),
400                input_responses: None,
401                request_state: None,
402                meta: None,
403                task: None,
404            }),
405        );
406        let resp = svc.call(req).await.unwrap();
407        let err = resp.inner.unwrap_err();
408        assert!(err.message.contains("not authorized"));
409    }
410
411    #[tokio::test]
412    async fn test_rbac_reader_allowed_read() {
413        let mock = MockService::with_tools(&["fs/read"]);
414        let mut svc = RbacService::new(mock, test_rbac_config());
415
416        let req = request_with_scope(
417            "read-only",
418            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
419                name: "fs/read".to_string(),
420                arguments: serde_json::json!({}),
421                input_responses: None,
422                request_state: None,
423                meta: None,
424                task: None,
425            }),
426        );
427        let resp = svc.call(req).await.unwrap();
428        assert!(resp.inner.is_ok(), "reader should call allowed tools");
429    }
430
431    #[tokio::test]
432    async fn test_rbac_filters_list_tools_for_role() {
433        let mock = MockService::with_tools(&["fs/read", "fs/write", "fs/delete"]);
434        let mut svc = RbacService::new(mock, test_rbac_config());
435
436        let req = request_with_scope("read-only", McpRequest::ListTools(Default::default()));
437        let resp = svc.call(req).await.unwrap();
438
439        match resp.inner.unwrap() {
440            McpResponse::ListTools(result) => {
441                let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
442                assert!(names.contains(&"fs/read"));
443                assert!(!names.contains(&"fs/write"));
444                assert!(!names.contains(&"fs/delete"));
445            }
446            other => panic!("expected ListTools, got: {:?}", other),
447        }
448    }
449
450    #[tokio::test]
451    async fn test_rbac_no_claims_passes_through() {
452        let mock = MockService::with_tools(&["fs/write"]);
453        let mut svc = RbacService::new(mock, test_rbac_config());
454
455        // No TokenClaims in extensions
456        let req = tower_mcp::RouterRequest {
457            id: RequestId::Number(1),
458            inner: McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
459                name: "fs/write".to_string(),
460                arguments: serde_json::json!({}),
461                input_responses: None,
462                request_state: None,
463                meta: None,
464                task: None,
465            }),
466            extensions: Extensions::new(),
467        };
468        let resp = svc.call(req).await.unwrap();
469        assert!(resp.inner.is_ok(), "no claims should pass through");
470    }
471
472    #[tokio::test]
473    async fn test_rbac_unmapped_scope_passes_through_by_default() {
474        // Valid claims, but the scope is not in the mapping. With default_deny
475        // = false (the default), this preserves legacy pass-through behavior.
476        let mock = MockService::with_tools(&["fs/write"]);
477        let mut svc = RbacService::new(mock, rbac_config_with_default_deny(false));
478
479        let req = request_with_scope(
480            "unknown-scope",
481            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
482                name: "fs/write".to_string(),
483                arguments: serde_json::json!({}),
484                input_responses: None,
485                request_state: None,
486                meta: None,
487                task: None,
488            }),
489        );
490        let resp = svc.call(req).await.unwrap();
491        assert!(
492            resp.inner.is_ok(),
493            "unmapped scope should pass through when default_deny is false"
494        );
495    }
496
497    #[tokio::test]
498    async fn test_rbac_unmapped_scope_denied_with_default_deny() {
499        // Valid claims, scope not in the mapping, default_deny = true: denied.
500        let mock = MockService::with_tools(&["fs/write"]);
501        let mut svc = RbacService::new(mock, rbac_config_with_default_deny(true));
502
503        let req = request_with_scope(
504            "unknown-scope",
505            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
506                name: "fs/write".to_string(),
507                arguments: serde_json::json!({}),
508                input_responses: None,
509                request_state: None,
510                meta: None,
511                task: None,
512            }),
513        );
514        let resp = svc.call(req).await.unwrap();
515        let err = resp.inner.unwrap_err();
516        assert!(
517            err.message.contains("default_deny"),
518            "unmapped scope should be denied when default_deny is true, got: {}",
519            err.message
520        );
521    }
522
523    #[tokio::test]
524    async fn test_rbac_mapped_scope_resolves_with_default_deny_enabled() {
525        // A recognized scope must still resolve its role regardless of the
526        // default_deny setting: reader can read, cannot write.
527        let mock = MockService::with_tools(&["fs/read", "fs/write"]);
528        let mut svc = RbacService::new(mock, rbac_config_with_default_deny(true));
529
530        let read_req = request_with_scope(
531            "read-only",
532            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
533                name: "fs/read".to_string(),
534                arguments: serde_json::json!({}),
535                input_responses: None,
536                request_state: None,
537                meta: None,
538                task: None,
539            }),
540        );
541        let resp = svc.call(read_req).await.unwrap();
542        assert!(
543            resp.inner.is_ok(),
544            "mapped role should still resolve with default_deny enabled"
545        );
546
547        let write_req = request_with_scope(
548            "read-only",
549            McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
550                name: "fs/write".to_string(),
551                arguments: serde_json::json!({}),
552                input_responses: None,
553                request_state: None,
554                meta: None,
555                task: None,
556            }),
557        );
558        let resp = svc.call(write_req).await.unwrap();
559        let err = resp.inner.unwrap_err();
560        assert!(
561            err.message.contains("not authorized"),
562            "reader should be denied write via role policy, got: {}",
563            err.message
564        );
565    }
566
567    #[tokio::test]
568    async fn test_rbac_no_claims_passes_through_with_default_deny() {
569        // default_deny must NOT affect the "no TokenClaims at all" case: a
570        // request with no claims always passes through.
571        let mock = MockService::with_tools(&["fs/write"]);
572        let mut svc = RbacService::new(mock, rbac_config_with_default_deny(true));
573
574        let req = tower_mcp::RouterRequest {
575            id: RequestId::Number(1),
576            inner: McpRequest::CallTool(tower_mcp::protocol::CallToolParams {
577                name: "fs/write".to_string(),
578                arguments: serde_json::json!({}),
579                input_responses: None,
580                request_state: None,
581                meta: None,
582                task: None,
583            }),
584            extensions: Extensions::new(),
585        };
586        let resp = svc.call(req).await.unwrap();
587        assert!(
588            resp.inner.is_ok(),
589            "no claims must pass through even when default_deny is true"
590        );
591    }
592}