1use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
12
13use axum::{
14 body::Body,
15 extract::ConnectInfo,
16 http::{Method, Request, StatusCode},
17 middleware::Next,
18 response::{IntoResponse, Response},
19};
20use hmac::{Hmac, KeyInit, Mac};
21use http_body_util::BodyExt;
22use secrecy::{ExposeSecret, SecretString};
23use serde::Deserialize;
24use sha2::Sha256;
25
26use crate::{
27 auth::{AuthIdentity, TlsConnInfo},
28 bounded_limiter::BoundedKeyedLimiter,
29 error::McpxError,
30};
31
32pub(crate) type ToolRateLimiter = BoundedKeyedLimiter<IpAddr>;
35
36const DEFAULT_TOOL_RATE: NonZeroU32 = NonZeroU32::new(120).unwrap();
39
40const DEFAULT_TOOL_MAX_TRACKED_KEYS: usize = 10_000;
43
44const DEFAULT_TOOL_IDLE_EVICTION: Duration = Duration::from_mins(15);
46
47#[must_use]
53pub(crate) fn build_tool_rate_limiter(
54 max_per_minute: u32,
55 burst: Option<u32>,
56) -> Arc<ToolRateLimiter> {
57 build_tool_rate_limiter_with_bounds(
58 max_per_minute,
59 burst,
60 DEFAULT_TOOL_MAX_TRACKED_KEYS,
61 DEFAULT_TOOL_IDLE_EVICTION,
62 )
63}
64
65#[must_use]
71pub(crate) fn build_tool_rate_limiter_with_bounds(
72 max_per_minute: u32,
73 burst: Option<u32>,
74 max_tracked_keys: usize,
75 idle_eviction: Duration,
76) -> Arc<ToolRateLimiter> {
77 let mut quota =
78 governor::Quota::per_minute(NonZeroU32::new(max_per_minute).unwrap_or(DEFAULT_TOOL_RATE));
79 if let Some(b) = burst.and_then(NonZeroU32::new) {
80 quota = quota.allow_burst(b);
81 }
82 Arc::new(BoundedKeyedLimiter::new(
83 quota,
84 max_tracked_keys,
85 idle_eviction,
86 ))
87}
88
89tokio::task_local! {
96 static CURRENT_ROLE: String;
97 static CURRENT_IDENTITY: String;
98 static CURRENT_TOKEN: SecretString;
99 static CURRENT_SUB: String;
100}
101
102#[must_use]
105pub fn current_role() -> Option<String> {
106 CURRENT_ROLE.try_with(Clone::clone).ok()
107}
108
109#[must_use]
112pub fn current_identity() -> Option<String> {
113 CURRENT_IDENTITY.try_with(Clone::clone).ok()
114}
115
116#[must_use]
129pub fn current_token() -> Option<SecretString> {
130 CURRENT_TOKEN
131 .try_with(|t| {
132 if t.expose_secret().is_empty() {
133 None
134 } else {
135 Some(t.clone())
136 }
137 })
138 .ok()
139 .flatten()
140}
141
142#[must_use]
146pub fn current_sub() -> Option<String> {
147 CURRENT_SUB
148 .try_with(Clone::clone)
149 .ok()
150 .filter(|s| !s.is_empty())
151}
152
153pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
158 CURRENT_TOKEN.scope(token, f).await
159}
160
161pub async fn with_rbac_scope<F: Future>(
166 role: String,
167 identity: String,
168 token: SecretString,
169 sub: String,
170 f: F,
171) -> F::Output {
172 CURRENT_ROLE
173 .scope(
174 role,
175 CURRENT_IDENTITY.scope(
176 identity,
177 CURRENT_TOKEN.scope(token, CURRENT_SUB.scope(sub, f)),
178 ),
179 )
180 .await
181}
182
183#[derive(Debug, Clone, Deserialize)]
185#[non_exhaustive]
186pub struct RoleConfig {
187 pub name: String,
189 #[serde(default)]
191 pub description: Option<String>,
192 #[serde(default)]
194 pub allow: Vec<String>,
195 #[serde(default)]
197 pub deny: Vec<String>,
198 #[serde(default = "default_hosts")]
200 pub hosts: Vec<String>,
201 #[serde(default)]
205 pub argument_allowlists: Vec<ArgumentAllowlist>,
206}
207
208impl RoleConfig {
209 #[must_use]
211 pub fn new(name: impl Into<String>, allow: Vec<String>, hosts: Vec<String>) -> Self {
212 Self {
213 name: name.into(),
214 description: None,
215 allow,
216 deny: vec![],
217 hosts,
218 argument_allowlists: vec![],
219 }
220 }
221
222 #[must_use]
224 pub fn with_argument_allowlists(mut self, allowlists: Vec<ArgumentAllowlist>) -> Self {
225 self.argument_allowlists = allowlists;
226 self
227 }
228}
229
230#[derive(Debug, Clone, Deserialize)]
253#[non_exhaustive]
254pub struct ArgumentAllowlist {
255 pub tool: String,
257 pub argument: String,
259 #[serde(default)]
261 pub allowed: Vec<String>,
262}
263
264impl ArgumentAllowlist {
265 #[must_use]
267 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
268 Self {
269 tool: tool.into(),
270 argument: argument.into(),
271 allowed,
272 }
273 }
274}
275
276fn default_hosts() -> Vec<String> {
277 vec!["*".into()]
278}
279
280#[derive(Debug, Clone, Default, Deserialize)]
282#[non_exhaustive]
283pub struct RbacConfig {
284 #[serde(default)]
286 pub enabled: bool,
287 #[serde(default)]
289 pub roles: Vec<RoleConfig>,
290 #[serde(default)]
299 pub redaction_salt: Option<SecretString>,
300}
301
302impl RbacConfig {
303 #[must_use]
305 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
306 Self {
307 enabled: true,
308 roles,
309 redaction_salt: None,
310 }
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316#[non_exhaustive]
317pub enum RbacDecision {
318 Allow,
320 Deny,
322}
323
324#[derive(Debug, Clone, serde::Serialize)]
326#[non_exhaustive]
327pub struct RbacRoleSummary {
328 pub name: String,
330 pub allow: usize,
332 pub deny: usize,
334 pub hosts: usize,
336 pub argument_allowlists: usize,
338}
339
340#[derive(Debug, Clone, serde::Serialize)]
342#[non_exhaustive]
343pub struct RbacPolicySummary {
344 pub enabled: bool,
346 pub roles: Vec<RbacRoleSummary>,
348}
349
350#[derive(Debug, Clone)]
356#[non_exhaustive]
357pub struct RbacPolicy {
358 roles: Vec<RoleConfig>,
359 enabled: bool,
360 redaction_salt: Arc<SecretString>,
363}
364
365impl RbacPolicy {
366 #[must_use]
369 pub fn new(config: &RbacConfig) -> Self {
370 let salt = config
371 .redaction_salt
372 .clone()
373 .unwrap_or_else(|| process_redaction_salt().clone());
374 Self {
375 roles: config.roles.clone(),
376 enabled: config.enabled,
377 redaction_salt: Arc::new(salt),
378 }
379 }
380
381 #[must_use]
383 pub fn disabled() -> Self {
384 Self {
385 roles: Vec::new(),
386 enabled: false,
387 redaction_salt: Arc::new(process_redaction_salt().clone()),
388 }
389 }
390
391 #[must_use]
393 pub fn is_enabled(&self) -> bool {
394 self.enabled
395 }
396
397 #[must_use]
402 pub fn summary(&self) -> RbacPolicySummary {
403 let roles = self
404 .roles
405 .iter()
406 .map(|r| RbacRoleSummary {
407 name: r.name.clone(),
408 allow: r.allow.len(),
409 deny: r.deny.len(),
410 hosts: r.hosts.len(),
411 argument_allowlists: r.argument_allowlists.len(),
412 })
413 .collect();
414 RbacPolicySummary {
415 enabled: self.enabled,
416 roles,
417 }
418 }
419
420 #[must_use]
425 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
426 if !self.enabled {
427 return RbacDecision::Allow;
428 }
429 let Some(role_cfg) = self.find_role(role) else {
430 return RbacDecision::Deny;
431 };
432 if role_cfg.deny.iter().any(|d| d == operation) {
433 return RbacDecision::Deny;
434 }
435 if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
436 return RbacDecision::Allow;
437 }
438 RbacDecision::Deny
439 }
440
441 #[must_use]
448 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
449 if !self.enabled {
450 return RbacDecision::Allow;
451 }
452 let Some(role_cfg) = self.find_role(role) else {
453 return RbacDecision::Deny;
454 };
455 if role_cfg.deny.iter().any(|d| d == operation) {
456 return RbacDecision::Deny;
457 }
458 if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
459 return RbacDecision::Deny;
460 }
461 if !Self::host_matches(&role_cfg.hosts, host) {
462 return RbacDecision::Deny;
463 }
464 RbacDecision::Allow
465 }
466
467 #[must_use]
469 pub fn host_visible(&self, role: &str, host: &str) -> bool {
470 if !self.enabled {
471 return true;
472 }
473 let Some(role_cfg) = self.find_role(role) else {
474 return false;
475 };
476 Self::host_matches(&role_cfg.hosts, host)
477 }
478
479 #[must_use]
481 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
482 self.find_role(role).map(|r| r.hosts.as_slice())
483 }
484
485 #[must_use]
514 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
515 if !self.enabled {
516 return true;
517 }
518 let Some(role_cfg) = self.find_role(role) else {
519 return false;
520 };
521 for al in &role_cfg.argument_allowlists {
522 if al.tool != tool && !glob_match(&al.tool, tool) {
523 continue;
524 }
525 if al.argument != argument {
526 continue;
527 }
528 if al.allowed.is_empty() {
529 continue;
530 }
531 let Some(tokens) = shlex::split(value) else {
536 return false;
537 };
538 let Some(first_token) = tokens.first() else {
539 return false;
540 };
541 if first_token.is_empty() {
545 return false;
546 }
547 let basename = first_token
551 .rsplit('/')
552 .next()
553 .unwrap_or(first_token.as_str());
554 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
555 return false;
556 }
557 }
558 true
559 }
560
561 #[must_use]
571 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
572 if !self.enabled {
573 return false;
574 }
575 let Some(role_cfg) = self.find_role(role) else {
576 return false;
577 };
578 role_cfg.argument_allowlists.iter().any(|al| {
579 (al.tool == tool || glob_match(&al.tool, tool))
580 && al.argument == argument
581 && !al.allowed.is_empty()
582 })
583 }
584
585 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
587 self.roles.iter().find(|r| r.name == name)
588 }
589
590 fn host_matches(patterns: &[String], host: &str) -> bool {
592 patterns.iter().any(|p| glob_match(p, host))
593 }
594
595 #[must_use]
604 pub fn redact_arg(&self, value: &str) -> String {
605 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
606 }
607}
608
609fn process_redaction_salt() -> &'static SecretString {
612 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
613 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
614 PROCESS_SALT.get_or_init(|| {
615 let mut bytes = [0u8; 32];
616 rand::fill(&mut bytes);
617 SecretString::from(STANDARD_NO_PAD.encode(bytes))
620 })
621}
622
623fn redact_with_salt(salt: &[u8], value: &str) -> String {
628 use std::fmt::Write as _;
629
630 use sha2::Digest as _;
631
632 type HmacSha256 = Hmac<Sha256>;
633 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
639 m
640 } else {
641 let digest = Sha256::digest(salt);
642 #[allow(
643 clippy::expect_used,
644 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
645 )]
646 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
647 };
648 mac.update(value.as_bytes());
649 let bytes = mac.finalize().into_bytes();
650 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
652 let mut out = String::with_capacity(8);
653 for b in prefix {
654 let _ = write!(out, "{b:02x}");
655 }
656 out
657}
658
659#[allow(
680 clippy::too_many_lines,
681 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
682)]
683pub(crate) async fn rbac_middleware(
684 policy: Arc<RbacPolicy>,
685 tool_limiter: Option<Arc<ToolRateLimiter>>,
686 req: Request<Body>,
687 next: Next,
688) -> Response {
689 if req.method() != Method::POST {
691 return next.run(req).await;
692 }
693
694 let peer_ip: Option<IpAddr> = req
696 .extensions()
697 .get::<ConnectInfo<std::net::SocketAddr>>()
698 .map(|ci| ci.0.ip())
699 .or_else(|| {
700 req.extensions()
701 .get::<ConnectInfo<TlsConnInfo>>()
702 .map(|ci| ci.0.addr.ip())
703 });
704
705 let identity = req.extensions().get::<AuthIdentity>();
707 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
708 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
709 let raw_token: SecretString = identity
712 .and_then(|id| id.raw_token.clone())
713 .unwrap_or_else(|| SecretString::from(String::new()));
714 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
715
716 if policy.is_enabled() && identity.is_none() {
718 return McpxError::Rbac("no authenticated identity".into()).into_response();
719 }
720
721 let (parts, body) = req.into_parts();
723 let bytes = match body.collect().await {
724 Ok(collected) => collected.to_bytes(),
725 Err(e) => {
726 tracing::error!(error = %e, "failed to read request body");
727 return (
728 StatusCode::INTERNAL_SERVER_ERROR,
729 "failed to read request body",
730 )
731 .into_response();
732 }
733 };
734
735 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
737 let tool_calls = extract_tool_calls(&json);
738 if !tool_calls.is_empty() {
739 for params in tool_calls {
740 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
741 return resp;
742 }
743 if policy.is_enabled()
744 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
745 {
746 return resp;
747 }
748 }
749 }
750 }
751 let req = Request::from_parts(parts, Body::from(bytes));
755
756 if role.is_empty() {
758 next.run(req).await
759 } else {
760 CURRENT_ROLE
761 .scope(
762 role,
763 CURRENT_IDENTITY.scope(
764 identity_name,
765 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
766 ),
767 )
768 .await
769 }
770}
771
772fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
778 match value {
779 serde_json::Value::Object(map) => map
780 .get("method")
781 .and_then(serde_json::Value::as_str)
782 .filter(|method| *method == "tools/call")
783 .and_then(|_| map.get("params"))
784 .into_iter()
785 .collect(),
786 serde_json::Value::Array(items) => items
787 .iter()
788 .filter_map(|item| match item {
789 serde_json::Value::Object(map) => map
790 .get("method")
791 .and_then(serde_json::Value::as_str)
792 .filter(|method| *method == "tools/call")
793 .and_then(|_| map.get("params")),
794 serde_json::Value::Null
795 | serde_json::Value::Bool(_)
796 | serde_json::Value::Number(_)
797 | serde_json::Value::String(_)
798 | serde_json::Value::Array(_) => None,
799 })
800 .collect(),
801 serde_json::Value::Null
802 | serde_json::Value::Bool(_)
803 | serde_json::Value::Number(_)
804 | serde_json::Value::String(_) => Vec::new(),
805 }
806}
807
808fn enforce_rate_limit(
811 tool_limiter: Option<&ToolRateLimiter>,
812 peer_ip: Option<IpAddr>,
813) -> Option<Response> {
814 let limiter = tool_limiter?;
815 let ip = peer_ip?;
816 if let Err(wait) = limiter.check_key_wait(&ip) {
817 tracing::warn!(%ip, "tool invocation rate limited");
818 return Some(
819 McpxError::RateLimitedFor {
820 message: "too many tool invocations".into(),
821 retry_after: wait,
822 }
823 .into_response(),
824 );
825 }
826 None
827}
828
829fn enforce_tool_policy(
838 policy: &RbacPolicy,
839 identity_name: &str,
840 role: &str,
841 params: &serde_json::Value,
842) -> Option<Response> {
843 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
844 let host = params
845 .get("arguments")
846 .and_then(|a| a.get("host"))
847 .and_then(|h| h.as_str());
848
849 let decision = if let Some(host) = host {
850 policy.check(role, tool_name, host)
851 } else {
852 policy.check_operation(role, tool_name)
853 };
854 if decision == RbacDecision::Deny {
855 tracing::warn!(
856 user = %identity_name,
857 role = %role,
858 tool = tool_name,
859 host = host.unwrap_or("-"),
860 "RBAC denied"
861 );
862 return Some(
863 McpxError::Rbac(format!("{tool_name} denied for role '{role}'")).into_response(),
864 );
865 }
866
867 let args = params.get("arguments").and_then(|a| a.as_object())?;
868 for (arg_key, arg_val) in args {
869 if let Some(resp) = check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
870 {
871 return Some(resp);
872 }
873 }
874 None
875}
876
877fn check_argument(
878 policy: &RbacPolicy,
879 identity_name: &str,
880 role: &str,
881 tool_name: &str,
882 arg_key: &str,
883 arg_val: &serde_json::Value,
884) -> Option<Response> {
885 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
886 return None;
887 }
888 let Some(val_str) = arg_val.as_str() else {
889 tracing::warn!(
895 user = %identity_name,
896 role = %role,
897 tool = tool_name,
898 argument = arg_key,
899 value_type = json_value_type(arg_val),
900 "non-string argument rejected by allowlist"
901 );
902 return Some(
903 McpxError::Rbac(format!(
904 "argument '{arg_key}' must be a string for tool '{tool_name}'"
905 ))
906 .into_response(),
907 );
908 };
909 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
910 return None;
911 }
912 tracing::warn!(
917 user = %identity_name,
918 role = %role,
919 tool = tool_name,
920 argument = arg_key,
921 arg_hmac = %policy.redact_arg(val_str),
922 "argument not in allowlist"
923 );
924 Some(
925 McpxError::Rbac(format!(
926 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
927 ))
928 .into_response(),
929 )
930}
931
932fn json_value_type(v: &serde_json::Value) -> &'static str {
933 match v {
934 serde_json::Value::Null => "null",
935 serde_json::Value::Bool(_) => "bool",
936 serde_json::Value::Number(_) => "number",
937 serde_json::Value::String(_) => "string",
938 serde_json::Value::Array(_) => "array",
939 serde_json::Value::Object(_) => "object",
940 }
941}
942
943fn glob_match(pattern: &str, text: &str) -> bool {
953 let parts: Vec<&str> = pattern.split('*').collect();
954 if parts.len() == 1 {
955 return pattern == text;
957 }
958
959 let mut pos = 0;
960
961 if let Some(&first) = parts.first()
963 && !first.is_empty()
964 {
965 if !text.starts_with(first) {
966 return false;
967 }
968 pos = first.len();
969 }
970
971 if let Some(&last) = parts.last()
973 && !last.is_empty()
974 {
975 if !text.get(pos..).unwrap_or_default().ends_with(last) {
976 return false;
977 }
978 let end = text.len() - last.len();
980 if pos > end {
981 return false;
982 }
983 let middle = text.get(pos..end).unwrap_or_default();
985 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
986 return match_middle(middle, middle_parts);
987 }
988
989 let middle = text.get(pos..).unwrap_or_default();
991 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
992 match_middle(middle, middle_parts)
993}
994
995fn match_middle(mut text: &str, parts: &[&str]) -> bool {
997 for part in parts {
998 if part.is_empty() {
999 continue;
1000 }
1001 if let Some(idx) = text.find(part) {
1002 text = text.get(idx + part.len()..).unwrap_or_default();
1003 } else {
1004 return false;
1005 }
1006 }
1007 true
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 #[test]
1019 fn tool_limiter_burst_allows_initial_spike() {
1020 let limiter = build_tool_rate_limiter(2, Some(4));
1021 let ip: IpAddr = "10.9.9.9".parse().unwrap();
1022 for i in 0..4 {
1023 assert!(
1024 limiter.check_key(&ip).is_ok(),
1025 "burst request {i} should pass"
1026 );
1027 }
1028 assert!(
1029 limiter.check_key(&ip).is_err(),
1030 "request 5 must exceed the burst bucket"
1031 );
1032 }
1033
1034 #[test]
1036 fn tool_limiter_deny_sets_retry_after() {
1037 let limiter = build_tool_rate_limiter(1, None);
1038 let ip: IpAddr = "10.8.8.8".parse().unwrap();
1039 assert!(enforce_rate_limit(Some(&limiter), Some(ip)).is_none());
1040 let resp = enforce_rate_limit(Some(&limiter), Some(ip))
1041 .expect("second call within the window must deny");
1042 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1043 let retry_after = resp
1044 .headers()
1045 .get(axum::http::header::RETRY_AFTER)
1046 .expect("Retry-After present")
1047 .to_str()
1048 .unwrap()
1049 .parse::<u64>()
1050 .unwrap();
1051 assert!(retry_after >= 1, "delta-seconds must be >= 1");
1052 }
1053
1054 fn test_policy() -> RbacPolicy {
1055 RbacPolicy::new(&RbacConfig {
1056 enabled: true,
1057 roles: vec![
1058 RoleConfig {
1059 name: "viewer".into(),
1060 description: Some("Read-only".into()),
1061 allow: vec![
1062 "list_hosts".into(),
1063 "resource_list".into(),
1064 "resource_inspect".into(),
1065 "resource_logs".into(),
1066 "system_info".into(),
1067 ],
1068 deny: vec![],
1069 hosts: vec!["*".into()],
1070 argument_allowlists: vec![],
1071 },
1072 RoleConfig {
1073 name: "deploy".into(),
1074 description: Some("Lifecycle management".into()),
1075 allow: vec![
1076 "list_hosts".into(),
1077 "resource_list".into(),
1078 "resource_run".into(),
1079 "resource_start".into(),
1080 "resource_stop".into(),
1081 "resource_restart".into(),
1082 "resource_logs".into(),
1083 "image_pull".into(),
1084 ],
1085 deny: vec!["resource_delete".into(), "resource_exec".into()],
1086 hosts: vec!["web-*".into(), "api-*".into()],
1087 argument_allowlists: vec![],
1088 },
1089 RoleConfig {
1090 name: "ops".into(),
1091 description: Some("Full access".into()),
1092 allow: vec!["*".into()],
1093 deny: vec![],
1094 hosts: vec!["*".into()],
1095 argument_allowlists: vec![],
1096 },
1097 RoleConfig {
1098 name: "restricted-exec".into(),
1099 description: Some("Exec with argument allowlist".into()),
1100 allow: vec!["resource_exec".into()],
1101 deny: vec![],
1102 hosts: vec!["dev-*".into()],
1103 argument_allowlists: vec![ArgumentAllowlist {
1104 tool: "resource_exec".into(),
1105 argument: "cmd".into(),
1106 allowed: vec![
1107 "sh".into(),
1108 "bash".into(),
1109 "cat".into(),
1110 "ls".into(),
1111 "ps".into(),
1112 ],
1113 }],
1114 },
1115 ],
1116 redaction_salt: None,
1117 })
1118 }
1119
1120 #[test]
1123 fn glob_exact_match() {
1124 assert!(glob_match("web-prod-1", "web-prod-1"));
1125 assert!(!glob_match("web-prod-1", "web-prod-2"));
1126 }
1127
1128 #[test]
1129 fn glob_star_suffix() {
1130 assert!(glob_match("web-*", "web-prod-1"));
1131 assert!(glob_match("web-*", "web-staging"));
1132 assert!(!glob_match("web-*", "api-prod"));
1133 }
1134
1135 #[test]
1136 fn glob_star_prefix() {
1137 assert!(glob_match("*-prod", "web-prod"));
1138 assert!(glob_match("*-prod", "api-prod"));
1139 assert!(!glob_match("*-prod", "web-staging"));
1140 }
1141
1142 #[test]
1143 fn glob_star_middle() {
1144 assert!(glob_match("web-*-prod", "web-us-prod"));
1145 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1146 assert!(!glob_match("web-*-prod", "web-staging"));
1147 }
1148
1149 #[test]
1150 fn glob_star_only() {
1151 assert!(glob_match("*", "anything"));
1152 assert!(glob_match("*", ""));
1153 }
1154
1155 #[test]
1156 fn glob_multiple_stars() {
1157 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1158 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1159 }
1160
1161 #[test]
1166 fn glob_match_multibyte_utf8() {
1167 assert!(glob_match("hé*llo", "héllo"));
1168 assert!(glob_match("*ö*", "wörld"));
1169 assert!(glob_match("über*", "übermensch"));
1170 assert!(glob_match("*界", "世界"));
1171 assert!(!glob_match("hé*llo", "hello"));
1172 assert!(!glob_match("界*", "世界"));
1173 assert!(glob_match("世*界", "世界"));
1174 }
1175
1176 #[test]
1188 fn glob_prefix_and_suffix_meet_exactly() {
1189 assert!(glob_match("ab*cd", "abcd"));
1192 }
1193
1194 #[test]
1199 fn glob_middle_segment_required_with_suffix() {
1200 assert!(!glob_match("a*b*c", "axyc"));
1205 }
1206
1207 #[test]
1213 fn glob_match_middle_advances_past_matched_part() {
1214 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1219 }
1220
1221 #[test]
1226 fn glob_match_middle_uses_addition_not_multiplication() {
1227 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1231 }
1232
1233 #[test]
1242 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1243 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1251 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1252 "run-*",
1253 "cmd",
1254 vec!["ls".into()],
1255 )]);
1256 let mut config = RbacConfig::with_roles(vec![role]);
1257 config.enabled = true;
1258 let policy = RbacPolicy::new(&config);
1259 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1260 }
1261
1262 #[test]
1265 fn disabled_policy_allows_everything() {
1266 let policy = RbacPolicy::new(&RbacConfig {
1267 enabled: false,
1268 roles: vec![],
1269 redaction_salt: None,
1270 });
1271 assert_eq!(
1272 policy.check("nonexistent", "resource_delete", "any-host"),
1273 RbacDecision::Allow
1274 );
1275 }
1276
1277 #[test]
1278 fn unknown_role_denied() {
1279 let policy = test_policy();
1280 assert_eq!(
1281 policy.check("unknown", "resource_list", "web-prod-1"),
1282 RbacDecision::Deny
1283 );
1284 }
1285
1286 #[test]
1287 fn viewer_allowed_read_ops() {
1288 let policy = test_policy();
1289 assert_eq!(
1290 policy.check("viewer", "resource_list", "web-prod-1"),
1291 RbacDecision::Allow
1292 );
1293 assert_eq!(
1294 policy.check("viewer", "system_info", "db-host"),
1295 RbacDecision::Allow
1296 );
1297 }
1298
1299 #[test]
1300 fn viewer_denied_write_ops() {
1301 let policy = test_policy();
1302 assert_eq!(
1303 policy.check("viewer", "resource_run", "web-prod-1"),
1304 RbacDecision::Deny
1305 );
1306 assert_eq!(
1307 policy.check("viewer", "resource_delete", "web-prod-1"),
1308 RbacDecision::Deny
1309 );
1310 }
1311
1312 #[test]
1313 fn deploy_allowed_on_matching_hosts() {
1314 let policy = test_policy();
1315 assert_eq!(
1316 policy.check("deploy", "resource_run", "web-prod-1"),
1317 RbacDecision::Allow
1318 );
1319 assert_eq!(
1320 policy.check("deploy", "resource_start", "api-staging"),
1321 RbacDecision::Allow
1322 );
1323 }
1324
1325 #[test]
1326 fn deploy_denied_on_non_matching_host() {
1327 let policy = test_policy();
1328 assert_eq!(
1329 policy.check("deploy", "resource_run", "db-prod-1"),
1330 RbacDecision::Deny
1331 );
1332 }
1333
1334 #[test]
1335 fn deny_overrides_allow() {
1336 let policy = test_policy();
1337 assert_eq!(
1338 policy.check("deploy", "resource_delete", "web-prod-1"),
1339 RbacDecision::Deny
1340 );
1341 assert_eq!(
1342 policy.check("deploy", "resource_exec", "web-prod-1"),
1343 RbacDecision::Deny
1344 );
1345 }
1346
1347 #[test]
1348 fn ops_wildcard_allows_everything() {
1349 let policy = test_policy();
1350 assert_eq!(
1351 policy.check("ops", "resource_delete", "any-host"),
1352 RbacDecision::Allow
1353 );
1354 assert_eq!(
1355 policy.check("ops", "secret_create", "db-host"),
1356 RbacDecision::Allow
1357 );
1358 }
1359
1360 #[test]
1363 fn host_visible_respects_globs() {
1364 let policy = test_policy();
1365 assert!(policy.host_visible("deploy", "web-prod-1"));
1366 assert!(policy.host_visible("deploy", "api-staging"));
1367 assert!(!policy.host_visible("deploy", "db-prod-1"));
1368 assert!(policy.host_visible("ops", "anything"));
1369 assert!(policy.host_visible("viewer", "anything"));
1370 }
1371
1372 #[test]
1373 fn host_visible_unknown_role() {
1374 let policy = test_policy();
1375 assert!(!policy.host_visible("unknown", "web-prod-1"));
1376 }
1377
1378 #[test]
1381 fn argument_allowed_no_allowlist() {
1382 let policy = test_policy();
1383 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1385 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1386 }
1387
1388 #[test]
1389 fn argument_allowed_with_allowlist() {
1390 let policy = test_policy();
1391 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1392 assert!(policy.argument_allowed(
1393 "restricted-exec",
1394 "resource_exec",
1395 "cmd",
1396 "bash -c 'echo hi'"
1397 ));
1398 assert!(policy.argument_allowed(
1399 "restricted-exec",
1400 "resource_exec",
1401 "cmd",
1402 "cat /etc/hosts"
1403 ));
1404 assert!(policy.argument_allowed(
1405 "restricted-exec",
1406 "resource_exec",
1407 "cmd",
1408 "/usr/bin/ls -la"
1409 ));
1410 }
1411
1412 #[test]
1413 fn argument_denied_not_in_allowlist() {
1414 let policy = test_policy();
1415 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1416 assert!(!policy.argument_allowed(
1417 "restricted-exec",
1418 "resource_exec",
1419 "cmd",
1420 "python3 exploit.py"
1421 ));
1422 assert!(!policy.argument_allowed(
1423 "restricted-exec",
1424 "resource_exec",
1425 "cmd",
1426 "/usr/bin/curl evil.com"
1427 ));
1428 }
1429
1430 #[test]
1431 fn argument_denied_unknown_role() {
1432 let policy = test_policy();
1433 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1434 }
1435
1436 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1445 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1446 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1447 let mut config = RbacConfig::with_roles(vec![role]);
1448 config.enabled = true;
1449 RbacPolicy::new(&config)
1450 }
1451
1452 #[test]
1453 fn argument_allowed_matches_quoted_path_with_spaces() {
1454 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1455 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1456 }
1457
1458 #[test]
1459 fn argument_allowed_matches_basename_of_quoted_path() {
1460 let policy = shlex_policy(vec!["my tool".into()]);
1461 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1462 }
1463
1464 #[test]
1465 fn argument_allowed_fails_closed_on_unbalanced_quote() {
1466 let policy = shlex_policy(vec!["unbalanced".into()]);
1467 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1468 }
1469
1470 #[test]
1471 fn argument_allowed_fails_closed_on_empty_string() {
1472 let policy = shlex_policy(vec![String::new()]);
1473 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1474 }
1475
1476 #[test]
1477 fn argument_allowed_handles_single_quoted_executable() {
1478 let policy = shlex_policy(vec!["/bin/sh".into()]);
1479 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1480 }
1481
1482 #[test]
1483 fn argument_allowed_handles_tab_separator() {
1484 let policy = shlex_policy(vec!["ls".into()]);
1485 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1486 }
1487
1488 #[test]
1489 fn argument_allowed_plain_token_unchanged() {
1490 let policy = shlex_policy(vec!["ls".into()]);
1491 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1492 }
1493
1494 #[test]
1500 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1501 let policy = shlex_policy(vec![String::new()]);
1505 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1506 }
1507
1508 #[test]
1509 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1510 let policy = shlex_policy(vec!["'bash'".into()]);
1516 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1517 }
1518
1519 #[test]
1520 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1521 let policy = shlex_policy(vec![r"foo\bar".into()]);
1526 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1527 }
1528
1529 #[test]
1530 fn argument_allowed_windows_path_no_longer_matches() {
1531 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1536 assert!(!policy.argument_allowed(
1537 "viewer",
1538 "run",
1539 "cmd",
1540 r"C:\Windows\System32\cmd.exe /c dir"
1541 ));
1542 }
1543
1544 #[test]
1547 fn host_patterns_returns_globs() {
1548 let policy = test_policy();
1549 assert_eq!(
1550 policy.host_patterns("deploy"),
1551 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1552 );
1553 assert_eq!(
1554 policy.host_patterns("ops"),
1555 Some(vec!["*".to_owned()].as_slice())
1556 );
1557 assert!(policy.host_patterns("nonexistent").is_none());
1558 }
1559
1560 #[test]
1563 fn check_operation_allows_without_host() {
1564 let policy = test_policy();
1565 assert_eq!(
1566 policy.check_operation("deploy", "resource_run"),
1567 RbacDecision::Allow
1568 );
1569 assert_eq!(
1571 policy.check("deploy", "resource_run", "db-prod-1"),
1572 RbacDecision::Deny
1573 );
1574 }
1575
1576 #[test]
1577 fn check_operation_deny_overrides() {
1578 let policy = test_policy();
1579 assert_eq!(
1580 policy.check_operation("deploy", "resource_delete"),
1581 RbacDecision::Deny
1582 );
1583 }
1584
1585 #[test]
1586 fn check_operation_unknown_role() {
1587 let policy = test_policy();
1588 assert_eq!(
1589 policy.check_operation("unknown", "resource_list"),
1590 RbacDecision::Deny
1591 );
1592 }
1593
1594 #[test]
1595 fn check_operation_disabled() {
1596 let policy = RbacPolicy::new(&RbacConfig {
1597 enabled: false,
1598 roles: vec![],
1599 redaction_salt: None,
1600 });
1601 assert_eq!(
1602 policy.check_operation("nonexistent", "anything"),
1603 RbacDecision::Allow
1604 );
1605 }
1606
1607 #[test]
1610 fn current_role_returns_none_outside_scope() {
1611 assert!(current_role().is_none());
1612 }
1613
1614 #[test]
1615 fn current_identity_returns_none_outside_scope() {
1616 assert!(current_identity().is_none());
1617 }
1618
1619 use axum::{
1622 body::Body,
1623 http::{Method, Request, StatusCode},
1624 };
1625 use tower::ServiceExt as _;
1626
1627 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1628 serde_json::json!({
1629 "jsonrpc": "2.0",
1630 "id": 1,
1631 "method": "tools/call",
1632 "params": {
1633 "name": tool,
1634 "arguments": args
1635 }
1636 })
1637 .to_string()
1638 }
1639
1640 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1641 axum::Router::new()
1642 .route("/mcp", axum::routing::post(|| async { "ok" }))
1643 .layer(axum::middleware::from_fn(move |req, next| {
1644 let p = Arc::clone(&policy);
1645 rbac_middleware(p, None, req, next)
1646 }))
1647 }
1648
1649 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1650 axum::Router::new()
1651 .route("/mcp", axum::routing::post(|| async { "ok" }))
1652 .layer(axum::middleware::from_fn(
1653 move |mut req: Request<Body>, next: Next| {
1654 let p = Arc::clone(&policy);
1655 let id = identity.clone();
1656 async move {
1657 req.extensions_mut().insert(id);
1658 rbac_middleware(p, None, req, next).await
1659 }
1660 },
1661 ))
1662 }
1663
1664 #[tokio::test]
1665 async fn middleware_passes_non_post() {
1666 let policy = Arc::new(test_policy());
1667 let app = rbac_router(policy);
1668 let req = Request::builder()
1670 .method(Method::GET)
1671 .uri("/mcp")
1672 .body(Body::empty())
1673 .unwrap();
1674 let resp = app.oneshot(req).await.unwrap();
1677 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1678 }
1679
1680 #[tokio::test]
1681 async fn middleware_denies_without_identity() {
1682 let policy = Arc::new(test_policy());
1683 let app = rbac_router(policy);
1684 let body = tool_call_body("resource_list", &serde_json::json!({}));
1685 let req = Request::builder()
1686 .method(Method::POST)
1687 .uri("/mcp")
1688 .header("content-type", "application/json")
1689 .body(Body::from(body))
1690 .unwrap();
1691 let resp = app.oneshot(req).await.unwrap();
1692 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1693 }
1694
1695 #[tokio::test]
1696 async fn middleware_allows_permitted_tool() {
1697 let policy = Arc::new(test_policy());
1698 let id = AuthIdentity {
1699 method: crate::auth::AuthMethod::BearerToken,
1700 name: "alice".into(),
1701 role: "viewer".into(),
1702 raw_token: None,
1703 sub: None,
1704 };
1705 let app = rbac_router_with_identity(policy, id);
1706 let body = tool_call_body("resource_list", &serde_json::json!({}));
1707 let req = Request::builder()
1708 .method(Method::POST)
1709 .uri("/mcp")
1710 .header("content-type", "application/json")
1711 .body(Body::from(body))
1712 .unwrap();
1713 let resp = app.oneshot(req).await.unwrap();
1714 assert_eq!(resp.status(), StatusCode::OK);
1715 }
1716
1717 #[tokio::test]
1718 async fn middleware_denies_unpermitted_tool() {
1719 let policy = Arc::new(test_policy());
1720 let id = AuthIdentity {
1721 method: crate::auth::AuthMethod::BearerToken,
1722 name: "alice".into(),
1723 role: "viewer".into(),
1724 raw_token: None,
1725 sub: None,
1726 };
1727 let app = rbac_router_with_identity(policy, id);
1728 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1729 let req = Request::builder()
1730 .method(Method::POST)
1731 .uri("/mcp")
1732 .header("content-type", "application/json")
1733 .body(Body::from(body))
1734 .unwrap();
1735 let resp = app.oneshot(req).await.unwrap();
1736 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1737 }
1738
1739 #[tokio::test]
1740 async fn middleware_passes_non_tool_call_post() {
1741 let policy = Arc::new(test_policy());
1742 let id = AuthIdentity {
1743 method: crate::auth::AuthMethod::BearerToken,
1744 name: "alice".into(),
1745 role: "viewer".into(),
1746 raw_token: None,
1747 sub: None,
1748 };
1749 let app = rbac_router_with_identity(policy, id);
1750 let body = serde_json::json!({
1752 "jsonrpc": "2.0",
1753 "id": 1,
1754 "method": "resources/list"
1755 })
1756 .to_string();
1757 let req = Request::builder()
1758 .method(Method::POST)
1759 .uri("/mcp")
1760 .header("content-type", "application/json")
1761 .body(Body::from(body))
1762 .unwrap();
1763 let resp = app.oneshot(req).await.unwrap();
1764 assert_eq!(resp.status(), StatusCode::OK);
1765 }
1766
1767 #[tokio::test]
1768 async fn middleware_enforces_argument_allowlist() {
1769 let policy = Arc::new(test_policy());
1770 let id = AuthIdentity {
1771 method: crate::auth::AuthMethod::BearerToken,
1772 name: "dev".into(),
1773 role: "restricted-exec".into(),
1774 raw_token: None,
1775 sub: None,
1776 };
1777 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1779 let body = tool_call_body(
1780 "resource_exec",
1781 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1782 );
1783 let req = Request::builder()
1784 .method(Method::POST)
1785 .uri("/mcp")
1786 .body(Body::from(body))
1787 .unwrap();
1788 let resp = app.oneshot(req).await.unwrap();
1789 assert_eq!(resp.status(), StatusCode::OK);
1790
1791 let app = rbac_router_with_identity(policy, id);
1793 let body = tool_call_body(
1794 "resource_exec",
1795 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1796 );
1797 let req = Request::builder()
1798 .method(Method::POST)
1799 .uri("/mcp")
1800 .body(Body::from(body))
1801 .unwrap();
1802 let resp = app.oneshot(req).await.unwrap();
1803 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1804 }
1805
1806 #[tokio::test]
1807 async fn middleware_disabled_policy_passes_everything() {
1808 let policy = Arc::new(RbacPolicy::disabled());
1809 let app = rbac_router(policy);
1810 let body = tool_call_body("anything", &serde_json::json!({}));
1812 let req = Request::builder()
1813 .method(Method::POST)
1814 .uri("/mcp")
1815 .body(Body::from(body))
1816 .unwrap();
1817 let resp = app.oneshot(req).await.unwrap();
1818 assert_eq!(resp.status(), StatusCode::OK);
1819 }
1820
1821 #[tokio::test]
1822 async fn middleware_batch_all_allowed_passes() {
1823 let policy = Arc::new(test_policy());
1824 let id = AuthIdentity {
1825 method: crate::auth::AuthMethod::BearerToken,
1826 name: "alice".into(),
1827 role: "viewer".into(),
1828 raw_token: None,
1829 sub: None,
1830 };
1831 let app = rbac_router_with_identity(policy, id);
1832 let body = serde_json::json!([
1833 {
1834 "jsonrpc": "2.0",
1835 "id": 1,
1836 "method": "tools/call",
1837 "params": { "name": "resource_list", "arguments": {} }
1838 },
1839 {
1840 "jsonrpc": "2.0",
1841 "id": 2,
1842 "method": "tools/call",
1843 "params": { "name": "system_info", "arguments": {} }
1844 }
1845 ])
1846 .to_string();
1847 let req = Request::builder()
1848 .method(Method::POST)
1849 .uri("/mcp")
1850 .header("content-type", "application/json")
1851 .body(Body::from(body))
1852 .unwrap();
1853 let resp = app.oneshot(req).await.unwrap();
1854 assert_eq!(resp.status(), StatusCode::OK);
1855 }
1856
1857 #[tokio::test]
1858 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
1859 let policy = Arc::new(test_policy());
1860 let id = AuthIdentity {
1861 method: crate::auth::AuthMethod::BearerToken,
1862 name: "alice".into(),
1863 role: "viewer".into(),
1864 raw_token: None,
1865 sub: None,
1866 };
1867 let app = rbac_router_with_identity(policy, id);
1868 let body = serde_json::json!([
1869 {
1870 "jsonrpc": "2.0",
1871 "id": 1,
1872 "method": "tools/call",
1873 "params": { "name": "resource_list", "arguments": {} }
1874 },
1875 {
1876 "jsonrpc": "2.0",
1877 "id": 2,
1878 "method": "tools/call",
1879 "params": { "name": "resource_delete", "arguments": {} }
1880 }
1881 ])
1882 .to_string();
1883 let req = Request::builder()
1884 .method(Method::POST)
1885 .uri("/mcp")
1886 .header("content-type", "application/json")
1887 .body(Body::from(body))
1888 .unwrap();
1889 let resp = app.oneshot(req).await.unwrap();
1890 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1891 }
1892
1893 #[tokio::test]
1894 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1895 let policy = Arc::new(test_policy());
1896 let id = AuthIdentity {
1897 method: crate::auth::AuthMethod::BearerToken,
1898 name: "dev".into(),
1899 role: "restricted-exec".into(),
1900 raw_token: None,
1901 sub: None,
1902 };
1903 let app = rbac_router_with_identity(policy, id);
1904 let body = serde_json::json!([
1905 {
1906 "jsonrpc": "2.0",
1907 "id": 1,
1908 "method": "tools/call",
1909 "params": {
1910 "name": "resource_exec",
1911 "arguments": { "cmd": "ls -la", "host": "dev-1" }
1912 }
1913 },
1914 {
1915 "jsonrpc": "2.0",
1916 "id": 2,
1917 "method": "tools/call",
1918 "params": {
1919 "name": "resource_exec",
1920 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1921 }
1922 }
1923 ])
1924 .to_string();
1925 let req = Request::builder()
1926 .method(Method::POST)
1927 .uri("/mcp")
1928 .header("content-type", "application/json")
1929 .body(Body::from(body))
1930 .unwrap();
1931 let resp = app.oneshot(req).await.unwrap();
1932 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1933 }
1934
1935 #[test]
1938 fn redact_with_salt_is_deterministic_per_salt() {
1939 let salt = b"unit-test-salt";
1940 let a = redact_with_salt(salt, "rm -rf /");
1941 let b = redact_with_salt(salt, "rm -rf /");
1942 assert_eq!(a, b, "same input + salt must yield identical hash");
1943 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1944 assert!(
1945 a.chars().all(|c| c.is_ascii_hexdigit()),
1946 "redacted hash must be lowercase hex: {a}"
1947 );
1948 }
1949
1950 #[test]
1951 fn redact_with_salt_differs_across_salts() {
1952 let v = "the-same-value";
1953 let h1 = redact_with_salt(b"salt-one", v);
1954 let h2 = redact_with_salt(b"salt-two", v);
1955 assert_ne!(
1956 h1, h2,
1957 "different salts must produce different hashes for the same value"
1958 );
1959 }
1960
1961 #[test]
1962 fn redact_with_salt_distinguishes_values() {
1963 let salt = b"k";
1964 let h1 = redact_with_salt(salt, "alpha");
1965 let h2 = redact_with_salt(salt, "beta");
1966 assert_ne!(h1, h2, "different values must produce different hashes");
1968 }
1969
1970 #[test]
1971 fn policy_with_configured_salt_redacts_consistently() {
1972 let cfg = RbacConfig {
1973 enabled: true,
1974 roles: vec![],
1975 redaction_salt: Some(SecretString::from("my-stable-salt")),
1976 };
1977 let p1 = RbacPolicy::new(&cfg);
1978 let p2 = RbacPolicy::new(&cfg);
1979 assert_eq!(
1980 p1.redact_arg("payload"),
1981 p2.redact_arg("payload"),
1982 "policies built from the same configured salt must agree"
1983 );
1984 }
1985
1986 #[test]
1987 fn policy_without_configured_salt_uses_process_salt() {
1988 let cfg = RbacConfig {
1989 enabled: true,
1990 roles: vec![],
1991 redaction_salt: None,
1992 };
1993 let p1 = RbacPolicy::new(&cfg);
1994 let p2 = RbacPolicy::new(&cfg);
1995 assert_eq!(
1997 p1.redact_arg("payload"),
1998 p2.redact_arg("payload"),
1999 "process-wide salt must be consistent within one process"
2000 );
2001 }
2002
2003 #[test]
2004 fn redact_arg_is_fast_enough() {
2005 let salt = b"perf-sanity-salt-32-bytes-padded";
2009 let value = "x".repeat(256);
2010 let start = std::time::Instant::now();
2011 let _ = redact_with_salt(salt, &value);
2012 let elapsed = start.elapsed();
2013 assert!(
2014 elapsed < Duration::from_millis(5),
2015 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
2016 );
2017 }
2018
2019 #[tokio::test]
2031 async fn deny_path_uses_explicit_identity_not_task_local() {
2032 let policy = Arc::new(test_policy());
2033 let id = AuthIdentity {
2034 method: crate::auth::AuthMethod::BearerToken,
2035 name: "alice-the-auditor".into(),
2036 role: "viewer".into(),
2037 raw_token: None,
2038 sub: None,
2039 };
2040 let app = rbac_router_with_identity(policy, id);
2041 let body = tool_call_body("resource_delete", &serde_json::json!({}));
2043 let req = Request::builder()
2044 .method(Method::POST)
2045 .uri("/mcp")
2046 .header("content-type", "application/json")
2047 .body(Body::from(body))
2048 .unwrap();
2049 let resp = app.oneshot(req).await.unwrap();
2050 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2051 }
2052
2053 fn restricted_exec_identity() -> AuthIdentity {
2056 AuthIdentity {
2057 method: crate::auth::AuthMethod::BearerToken,
2058 name: "carol".into(),
2059 role: "restricted-exec".into(),
2060 raw_token: None,
2061 sub: None,
2062 }
2063 }
2064
2065 #[test]
2066 fn has_argument_allowlist_matches_configured_tool_argument() {
2067 let policy = test_policy();
2068 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2069 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2070 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2071 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2072 }
2073
2074 #[tokio::test]
2075 async fn array_arg_with_matching_allowlist_is_denied() {
2076 let policy = Arc::new(test_policy());
2077 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2078 let body = tool_call_body(
2079 "resource_exec",
2080 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2081 );
2082 let req = Request::builder()
2083 .method(Method::POST)
2084 .uri("/mcp")
2085 .header("content-type", "application/json")
2086 .body(Body::from(body))
2087 .unwrap();
2088 let resp = app.oneshot(req).await.unwrap();
2089 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2090 }
2091
2092 #[tokio::test]
2093 async fn object_arg_with_matching_allowlist_is_denied() {
2094 let policy = Arc::new(test_policy());
2095 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2096 let body = tool_call_body(
2097 "resource_exec",
2098 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2099 );
2100 let req = Request::builder()
2101 .method(Method::POST)
2102 .uri("/mcp")
2103 .header("content-type", "application/json")
2104 .body(Body::from(body))
2105 .unwrap();
2106 let resp = app.oneshot(req).await.unwrap();
2107 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2108 }
2109
2110 #[tokio::test]
2111 async fn number_arg_with_matching_allowlist_is_denied() {
2112 let policy = Arc::new(test_policy());
2113 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2114 let body = tool_call_body(
2115 "resource_exec",
2116 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2117 );
2118 let req = Request::builder()
2119 .method(Method::POST)
2120 .uri("/mcp")
2121 .header("content-type", "application/json")
2122 .body(Body::from(body))
2123 .unwrap();
2124 let resp = app.oneshot(req).await.unwrap();
2125 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2126 }
2127
2128 #[tokio::test]
2129 async fn bool_arg_with_matching_allowlist_is_denied() {
2130 let policy = Arc::new(test_policy());
2131 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2132 let body = tool_call_body(
2133 "resource_exec",
2134 &serde_json::json!({ "host": "dev-1", "cmd": true }),
2135 );
2136 let req = Request::builder()
2137 .method(Method::POST)
2138 .uri("/mcp")
2139 .header("content-type", "application/json")
2140 .body(Body::from(body))
2141 .unwrap();
2142 let resp = app.oneshot(req).await.unwrap();
2143 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2144 }
2145
2146 #[tokio::test]
2147 async fn null_arg_with_matching_allowlist_is_denied() {
2148 let policy = Arc::new(test_policy());
2149 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2150 let body = tool_call_body(
2151 "resource_exec",
2152 &serde_json::json!({ "host": "dev-1", "cmd": null }),
2153 );
2154 let req = Request::builder()
2155 .method(Method::POST)
2156 .uri("/mcp")
2157 .header("content-type", "application/json")
2158 .body(Body::from(body))
2159 .unwrap();
2160 let resp = app.oneshot(req).await.unwrap();
2161 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2162 }
2163
2164 #[tokio::test]
2165 async fn non_string_arg_without_allowlist_is_passthrough() {
2166 let policy = Arc::new(test_policy());
2170 let id = AuthIdentity {
2171 method: crate::auth::AuthMethod::BearerToken,
2172 name: "olivia".into(),
2173 role: "ops".into(),
2174 raw_token: None,
2175 sub: None,
2176 };
2177 let app = rbac_router_with_identity(policy, id);
2178 let body = tool_call_body(
2179 "resource_exec",
2180 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2181 );
2182 let req = Request::builder()
2183 .method(Method::POST)
2184 .uri("/mcp")
2185 .header("content-type", "application/json")
2186 .body(Body::from(body))
2187 .unwrap();
2188 let resp = app.oneshot(req).await.unwrap();
2189 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2190 }
2191
2192 #[tokio::test]
2193 async fn string_arg_in_allowlist_still_passes() {
2194 let policy = Arc::new(test_policy());
2195 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2196 let body = tool_call_body(
2197 "resource_exec",
2198 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2199 );
2200 let req = Request::builder()
2201 .method(Method::POST)
2202 .uri("/mcp")
2203 .header("content-type", "application/json")
2204 .body(Body::from(body))
2205 .unwrap();
2206 let resp = app.oneshot(req).await.unwrap();
2207 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2208 }
2209}