1use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
12
13use axum::{
14 body::Body,
15 http::{Method, Request, StatusCode},
16 middleware::Next,
17 response::{IntoResponse, Response},
18};
19use hmac::{Hmac, KeyInit, Mac};
20use http_body_util::BodyExt;
21use secrecy::{ExposeSecret, SecretString};
22use serde::Deserialize;
23use sha2::Sha256;
24
25use crate::{auth::AuthIdentity, bounded_limiter::BoundedKeyedLimiter, error::McpxError};
26
27pub(crate) type ToolRateLimiter = BoundedKeyedLimiter<IpAddr>;
30
31const DEFAULT_TOOL_RATE: NonZeroU32 = NonZeroU32::new(120).unwrap();
34
35const DEFAULT_TOOL_MAX_TRACKED_KEYS: usize = 10_000;
38
39const DEFAULT_TOOL_IDLE_EVICTION: Duration = Duration::from_mins(15);
41
42#[must_use]
48pub(crate) fn build_tool_rate_limiter(
49 max_per_minute: u32,
50 burst: Option<u32>,
51) -> Arc<ToolRateLimiter> {
52 build_tool_rate_limiter_with_bounds(
53 max_per_minute,
54 burst,
55 DEFAULT_TOOL_MAX_TRACKED_KEYS,
56 DEFAULT_TOOL_IDLE_EVICTION,
57 )
58}
59
60#[must_use]
66pub(crate) fn build_tool_rate_limiter_with_bounds(
67 max_per_minute: u32,
68 burst: Option<u32>,
69 max_tracked_keys: usize,
70 idle_eviction: Duration,
71) -> Arc<ToolRateLimiter> {
72 let mut quota =
73 governor::Quota::per_minute(NonZeroU32::new(max_per_minute).unwrap_or(DEFAULT_TOOL_RATE));
74 if let Some(b) = burst.and_then(NonZeroU32::new) {
75 quota = quota.allow_burst(b);
76 }
77 Arc::new(BoundedKeyedLimiter::new(
78 quota,
79 max_tracked_keys,
80 idle_eviction,
81 ))
82}
83
84tokio::task_local! {
91 static CURRENT_ROLE: String;
92 static CURRENT_IDENTITY: String;
93 static CURRENT_TOKEN: SecretString;
94 static CURRENT_SUB: String;
95}
96
97#[must_use]
100pub fn current_role() -> Option<String> {
101 CURRENT_ROLE.try_with(Clone::clone).ok()
102}
103
104#[must_use]
107pub fn current_identity() -> Option<String> {
108 CURRENT_IDENTITY.try_with(Clone::clone).ok()
109}
110
111#[must_use]
124pub fn current_token() -> Option<SecretString> {
125 CURRENT_TOKEN
126 .try_with(|t| {
127 if t.expose_secret().is_empty() {
128 None
129 } else {
130 Some(t.clone())
131 }
132 })
133 .ok()
134 .flatten()
135}
136
137#[must_use]
141pub fn current_sub() -> Option<String> {
142 CURRENT_SUB
143 .try_with(Clone::clone)
144 .ok()
145 .filter(|s| !s.is_empty())
146}
147
148pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
153 CURRENT_TOKEN.scope(token, f).await
154}
155
156pub async fn with_rbac_scope<F: Future>(
161 role: String,
162 identity: String,
163 token: SecretString,
164 sub: String,
165 f: F,
166) -> F::Output {
167 CURRENT_ROLE
168 .scope(
169 role,
170 CURRENT_IDENTITY.scope(
171 identity,
172 CURRENT_TOKEN.scope(token, CURRENT_SUB.scope(sub, f)),
173 ),
174 )
175 .await
176}
177
178#[derive(Debug, Clone, Deserialize)]
180#[non_exhaustive]
181pub struct RoleConfig {
182 pub name: String,
184 #[serde(default)]
186 pub description: Option<String>,
187 #[serde(default)]
189 pub allow: Vec<String>,
190 #[serde(default)]
192 pub deny: Vec<String>,
193 #[serde(default = "default_hosts")]
195 pub hosts: Vec<String>,
196 #[serde(default)]
200 pub argument_allowlists: Vec<ArgumentAllowlist>,
201}
202
203impl RoleConfig {
204 #[must_use]
206 pub fn new(name: impl Into<String>, allow: Vec<String>, hosts: Vec<String>) -> Self {
207 Self {
208 name: name.into(),
209 description: None,
210 allow,
211 deny: vec![],
212 hosts,
213 argument_allowlists: vec![],
214 }
215 }
216
217 #[must_use]
219 pub fn with_argument_allowlists(mut self, allowlists: Vec<ArgumentAllowlist>) -> Self {
220 self.argument_allowlists = allowlists;
221 self
222 }
223}
224
225#[derive(Debug, Clone, Deserialize)]
248#[non_exhaustive]
249pub struct ArgumentAllowlist {
250 pub tool: String,
252 pub argument: String,
254 #[serde(default)]
256 pub allowed: Vec<String>,
257}
258
259impl ArgumentAllowlist {
260 #[must_use]
262 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
263 Self {
264 tool: tool.into(),
265 argument: argument.into(),
266 allowed,
267 }
268 }
269}
270
271fn default_hosts() -> Vec<String> {
272 vec!["*".into()]
273}
274
275#[derive(Debug, Clone, Default, Deserialize)]
277#[non_exhaustive]
278pub struct RbacConfig {
279 #[serde(default)]
281 pub enabled: bool,
282 #[serde(default)]
284 pub roles: Vec<RoleConfig>,
285 #[serde(default)]
294 pub redaction_salt: Option<SecretString>,
295}
296
297impl RbacConfig {
298 #[must_use]
300 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
301 Self {
302 enabled: true,
303 roles,
304 redaction_salt: None,
305 }
306 }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311#[non_exhaustive]
312pub enum RbacDecision {
313 Allow,
315 Deny,
317}
318
319#[derive(Debug, Clone, serde::Serialize)]
321#[non_exhaustive]
322pub struct RbacRoleSummary {
323 pub name: String,
325 pub allow: usize,
327 pub deny: usize,
329 pub hosts: usize,
331 pub argument_allowlists: usize,
333}
334
335#[derive(Debug, Clone, serde::Serialize)]
337#[non_exhaustive]
338pub struct RbacPolicySummary {
339 pub enabled: bool,
341 pub roles: Vec<RbacRoleSummary>,
343}
344
345#[derive(Debug, Clone)]
351#[non_exhaustive]
352pub struct RbacPolicy {
353 roles: Vec<RoleConfig>,
354 enabled: bool,
355 redaction_salt: Arc<SecretString>,
358}
359
360impl RbacPolicy {
361 #[must_use]
364 pub fn new(config: &RbacConfig) -> Self {
365 let salt = config
366 .redaction_salt
367 .clone()
368 .unwrap_or_else(|| process_redaction_salt().clone());
369 Self {
370 roles: config.roles.clone(),
371 enabled: config.enabled,
372 redaction_salt: Arc::new(salt),
373 }
374 }
375
376 #[must_use]
378 pub fn disabled() -> Self {
379 Self {
380 roles: Vec::new(),
381 enabled: false,
382 redaction_salt: Arc::new(process_redaction_salt().clone()),
383 }
384 }
385
386 #[must_use]
388 pub fn is_enabled(&self) -> bool {
389 self.enabled
390 }
391
392 #[must_use]
397 pub fn summary(&self) -> RbacPolicySummary {
398 let roles = self
399 .roles
400 .iter()
401 .map(|r| RbacRoleSummary {
402 name: r.name.clone(),
403 allow: r.allow.len(),
404 deny: r.deny.len(),
405 hosts: r.hosts.len(),
406 argument_allowlists: r.argument_allowlists.len(),
407 })
408 .collect();
409 RbacPolicySummary {
410 enabled: self.enabled,
411 roles,
412 }
413 }
414
415 #[must_use]
420 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
421 if !self.enabled {
422 return RbacDecision::Allow;
423 }
424 let Some(role_cfg) = self.find_role(role) else {
425 return RbacDecision::Deny;
426 };
427 if role_cfg.deny.iter().any(|d| d == operation) {
428 return RbacDecision::Deny;
429 }
430 if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
431 return RbacDecision::Allow;
432 }
433 RbacDecision::Deny
434 }
435
436 #[must_use]
443 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
444 if !self.enabled {
445 return RbacDecision::Allow;
446 }
447 let Some(role_cfg) = self.find_role(role) else {
448 return RbacDecision::Deny;
449 };
450 if role_cfg.deny.iter().any(|d| d == operation) {
451 return RbacDecision::Deny;
452 }
453 if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
454 return RbacDecision::Deny;
455 }
456 if !Self::host_matches(&role_cfg.hosts, host) {
457 return RbacDecision::Deny;
458 }
459 RbacDecision::Allow
460 }
461
462 #[must_use]
464 pub fn host_visible(&self, role: &str, host: &str) -> bool {
465 if !self.enabled {
466 return true;
467 }
468 let Some(role_cfg) = self.find_role(role) else {
469 return false;
470 };
471 Self::host_matches(&role_cfg.hosts, host)
472 }
473
474 #[must_use]
476 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
477 self.find_role(role).map(|r| r.hosts.as_slice())
478 }
479
480 #[must_use]
509 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
510 if !self.enabled {
511 return true;
512 }
513 let Some(role_cfg) = self.find_role(role) else {
514 return false;
515 };
516 for al in &role_cfg.argument_allowlists {
517 if al.tool != tool && !glob_match(&al.tool, tool) {
518 continue;
519 }
520 if al.argument != argument {
521 continue;
522 }
523 if al.allowed.is_empty() {
524 continue;
525 }
526 let Some(tokens) = shlex::split(value) else {
531 return false;
532 };
533 let Some(first_token) = tokens.first() else {
534 return false;
535 };
536 if first_token.is_empty() {
540 return false;
541 }
542 let basename = first_token
546 .rsplit('/')
547 .next()
548 .unwrap_or(first_token.as_str());
549 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
550 return false;
551 }
552 }
553 true
554 }
555
556 #[must_use]
566 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
567 if !self.enabled {
568 return false;
569 }
570 let Some(role_cfg) = self.find_role(role) else {
571 return false;
572 };
573 role_cfg.argument_allowlists.iter().any(|al| {
574 (al.tool == tool || glob_match(&al.tool, tool))
575 && al.argument == argument
576 && !al.allowed.is_empty()
577 })
578 }
579
580 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
582 self.roles.iter().find(|r| r.name == name)
583 }
584
585 fn host_matches(patterns: &[String], host: &str) -> bool {
587 patterns.iter().any(|p| glob_match(p, host))
588 }
589
590 #[must_use]
599 pub fn redact_arg(&self, value: &str) -> String {
600 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
601 }
602}
603
604fn process_redaction_salt() -> &'static SecretString {
607 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
608 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
609 PROCESS_SALT.get_or_init(|| {
610 let mut bytes = [0u8; 32];
611 rand::fill(&mut bytes);
612 SecretString::from(STANDARD_NO_PAD.encode(bytes))
615 })
616}
617
618fn redact_with_salt(salt: &[u8], value: &str) -> String {
623 use std::fmt::Write as _;
624
625 use sha2::Digest as _;
626
627 type HmacSha256 = Hmac<Sha256>;
628 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
634 m
635 } else {
636 let digest = Sha256::digest(salt);
637 #[allow(
638 clippy::expect_used,
639 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
640 )]
641 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
642 };
643 mac.update(value.as_bytes());
644 let bytes = mac.finalize().into_bytes();
645 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
647 let mut out = String::with_capacity(8);
648 for b in prefix {
649 let _ = write!(out, "{b:02x}");
650 }
651 out
652}
653
654#[allow(
675 clippy::too_many_lines,
676 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
677)]
678pub(crate) async fn rbac_middleware(
679 policy: Arc<RbacPolicy>,
680 tool_limiter: Option<Arc<ToolRateLimiter>>,
681 req: Request<Body>,
682 next: Next,
683) -> Response {
684 if req.method() != Method::POST {
686 return next.run(req).await;
687 }
688
689 let peer_ip: Option<IpAddr> = crate::transport::limiter_client_ip(req.extensions());
692
693 let identity = req.extensions().get::<AuthIdentity>();
695 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
696 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
697 let raw_token: SecretString = identity
700 .and_then(|id| id.raw_token.clone())
701 .unwrap_or_else(|| SecretString::from(String::new()));
702 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
703
704 if policy.is_enabled() && identity.is_none() {
706 return McpxError::Rbac("no authenticated identity".into()).into_response();
707 }
708
709 let (parts, body) = req.into_parts();
711 let bytes = match body.collect().await {
712 Ok(collected) => collected.to_bytes(),
713 Err(e) => {
714 tracing::error!(error = %e, "failed to read request body");
715 return (
716 StatusCode::INTERNAL_SERVER_ERROR,
717 "failed to read request body",
718 )
719 .into_response();
720 }
721 };
722
723 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
725 let tool_calls = extract_tool_calls(&json);
726 if !tool_calls.is_empty() {
727 for params in tool_calls {
728 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
729 return resp;
730 }
731 if policy.is_enabled()
732 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
733 {
734 return resp;
735 }
736 }
737 }
738 }
739 let req = Request::from_parts(parts, Body::from(bytes));
743
744 if role.is_empty() {
746 next.run(req).await
747 } else {
748 CURRENT_ROLE
749 .scope(
750 role,
751 CURRENT_IDENTITY.scope(
752 identity_name,
753 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
754 ),
755 )
756 .await
757 }
758}
759
760fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
766 match value {
767 serde_json::Value::Object(map) => map
768 .get("method")
769 .and_then(serde_json::Value::as_str)
770 .filter(|method| *method == "tools/call")
771 .and_then(|_| map.get("params"))
772 .into_iter()
773 .collect(),
774 serde_json::Value::Array(items) => items
775 .iter()
776 .filter_map(|item| match item {
777 serde_json::Value::Object(map) => map
778 .get("method")
779 .and_then(serde_json::Value::as_str)
780 .filter(|method| *method == "tools/call")
781 .and_then(|_| map.get("params")),
782 serde_json::Value::Null
783 | serde_json::Value::Bool(_)
784 | serde_json::Value::Number(_)
785 | serde_json::Value::String(_)
786 | serde_json::Value::Array(_) => None,
787 })
788 .collect(),
789 serde_json::Value::Null
790 | serde_json::Value::Bool(_)
791 | serde_json::Value::Number(_)
792 | serde_json::Value::String(_) => Vec::new(),
793 }
794}
795
796fn enforce_rate_limit(
799 tool_limiter: Option<&ToolRateLimiter>,
800 peer_ip: Option<IpAddr>,
801) -> Option<Response> {
802 let limiter = tool_limiter?;
803 let ip = peer_ip?;
804 if let Err(wait) = limiter.check_key_wait(&ip) {
805 tracing::warn!(%ip, "tool invocation rate limited");
806 return Some(
807 McpxError::RateLimitedFor {
808 message: "too many tool invocations".into(),
809 retry_after: wait,
810 }
811 .into_response(),
812 );
813 }
814 None
815}
816
817fn enforce_tool_policy(
826 policy: &RbacPolicy,
827 identity_name: &str,
828 role: &str,
829 params: &serde_json::Value,
830) -> Option<Response> {
831 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
832 let host = params
833 .get("arguments")
834 .and_then(|a| a.get("host"))
835 .and_then(|h| h.as_str());
836
837 let decision = if let Some(host) = host {
838 policy.check(role, tool_name, host)
839 } else {
840 policy.check_operation(role, tool_name)
841 };
842 if decision == RbacDecision::Deny {
843 tracing::warn!(
844 user = %identity_name,
845 role = %role,
846 tool = tool_name,
847 host = host.unwrap_or("-"),
848 "RBAC denied"
849 );
850 return Some(
851 McpxError::Rbac(format!("{tool_name} denied for role '{role}'")).into_response(),
852 );
853 }
854
855 let args = params.get("arguments").and_then(|a| a.as_object())?;
856 for (arg_key, arg_val) in args {
857 if let Some(resp) = check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
858 {
859 return Some(resp);
860 }
861 }
862 None
863}
864
865fn check_argument(
866 policy: &RbacPolicy,
867 identity_name: &str,
868 role: &str,
869 tool_name: &str,
870 arg_key: &str,
871 arg_val: &serde_json::Value,
872) -> Option<Response> {
873 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
874 return None;
875 }
876 let Some(val_str) = arg_val.as_str() else {
877 tracing::warn!(
883 user = %identity_name,
884 role = %role,
885 tool = tool_name,
886 argument = arg_key,
887 value_type = json_value_type(arg_val),
888 "non-string argument rejected by allowlist"
889 );
890 return Some(
891 McpxError::Rbac(format!(
892 "argument '{arg_key}' must be a string for tool '{tool_name}'"
893 ))
894 .into_response(),
895 );
896 };
897 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
898 return None;
899 }
900 tracing::warn!(
905 user = %identity_name,
906 role = %role,
907 tool = tool_name,
908 argument = arg_key,
909 arg_hmac = %policy.redact_arg(val_str),
910 "argument not in allowlist"
911 );
912 Some(
913 McpxError::Rbac(format!(
914 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
915 ))
916 .into_response(),
917 )
918}
919
920fn json_value_type(v: &serde_json::Value) -> &'static str {
921 match v {
922 serde_json::Value::Null => "null",
923 serde_json::Value::Bool(_) => "bool",
924 serde_json::Value::Number(_) => "number",
925 serde_json::Value::String(_) => "string",
926 serde_json::Value::Array(_) => "array",
927 serde_json::Value::Object(_) => "object",
928 }
929}
930
931fn glob_match(pattern: &str, text: &str) -> bool {
941 let parts: Vec<&str> = pattern.split('*').collect();
942 if parts.len() == 1 {
943 return pattern == text;
945 }
946
947 let mut pos = 0;
948
949 if let Some(&first) = parts.first()
951 && !first.is_empty()
952 {
953 if !text.starts_with(first) {
954 return false;
955 }
956 pos = first.len();
957 }
958
959 if let Some(&last) = parts.last()
961 && !last.is_empty()
962 {
963 if !text.get(pos..).unwrap_or_default().ends_with(last) {
964 return false;
965 }
966 let end = text.len() - last.len();
968 if pos > end {
969 return false;
970 }
971 let middle = text.get(pos..end).unwrap_or_default();
973 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
974 return match_middle(middle, middle_parts);
975 }
976
977 let middle = text.get(pos..).unwrap_or_default();
979 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
980 match_middle(middle, middle_parts)
981}
982
983fn match_middle(mut text: &str, parts: &[&str]) -> bool {
985 for part in parts {
986 if part.is_empty() {
987 continue;
988 }
989 if let Some(idx) = text.find(part) {
990 text = text.get(idx + part.len()..).unwrap_or_default();
991 } else {
992 return false;
993 }
994 }
995 true
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 #[test]
1007 fn tool_limiter_burst_allows_initial_spike() {
1008 let limiter = build_tool_rate_limiter(2, Some(4));
1009 let ip: IpAddr = "10.9.9.9".parse().unwrap();
1010 for i in 0..4 {
1011 assert!(
1012 limiter.check_key(&ip).is_ok(),
1013 "burst request {i} should pass"
1014 );
1015 }
1016 assert!(
1017 limiter.check_key(&ip).is_err(),
1018 "request 5 must exceed the burst bucket"
1019 );
1020 }
1021
1022 #[test]
1024 fn tool_limiter_deny_sets_retry_after() {
1025 let limiter = build_tool_rate_limiter(1, None);
1026 let ip: IpAddr = "10.8.8.8".parse().unwrap();
1027 assert!(enforce_rate_limit(Some(&limiter), Some(ip)).is_none());
1028 let resp = enforce_rate_limit(Some(&limiter), Some(ip))
1029 .expect("second call within the window must deny");
1030 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1031 let retry_after = resp
1032 .headers()
1033 .get(axum::http::header::RETRY_AFTER)
1034 .expect("Retry-After present")
1035 .to_str()
1036 .unwrap()
1037 .parse::<u64>()
1038 .unwrap();
1039 assert!(retry_after >= 1, "delta-seconds must be >= 1");
1040 }
1041
1042 fn test_policy() -> RbacPolicy {
1043 RbacPolicy::new(&RbacConfig {
1044 enabled: true,
1045 roles: vec![
1046 RoleConfig {
1047 name: "viewer".into(),
1048 description: Some("Read-only".into()),
1049 allow: vec![
1050 "list_hosts".into(),
1051 "resource_list".into(),
1052 "resource_inspect".into(),
1053 "resource_logs".into(),
1054 "system_info".into(),
1055 ],
1056 deny: vec![],
1057 hosts: vec!["*".into()],
1058 argument_allowlists: vec![],
1059 },
1060 RoleConfig {
1061 name: "deploy".into(),
1062 description: Some("Lifecycle management".into()),
1063 allow: vec![
1064 "list_hosts".into(),
1065 "resource_list".into(),
1066 "resource_run".into(),
1067 "resource_start".into(),
1068 "resource_stop".into(),
1069 "resource_restart".into(),
1070 "resource_logs".into(),
1071 "image_pull".into(),
1072 ],
1073 deny: vec!["resource_delete".into(), "resource_exec".into()],
1074 hosts: vec!["web-*".into(), "api-*".into()],
1075 argument_allowlists: vec![],
1076 },
1077 RoleConfig {
1078 name: "ops".into(),
1079 description: Some("Full access".into()),
1080 allow: vec!["*".into()],
1081 deny: vec![],
1082 hosts: vec!["*".into()],
1083 argument_allowlists: vec![],
1084 },
1085 RoleConfig {
1086 name: "restricted-exec".into(),
1087 description: Some("Exec with argument allowlist".into()),
1088 allow: vec!["resource_exec".into()],
1089 deny: vec![],
1090 hosts: vec!["dev-*".into()],
1091 argument_allowlists: vec![ArgumentAllowlist {
1092 tool: "resource_exec".into(),
1093 argument: "cmd".into(),
1094 allowed: vec![
1095 "sh".into(),
1096 "bash".into(),
1097 "cat".into(),
1098 "ls".into(),
1099 "ps".into(),
1100 ],
1101 }],
1102 },
1103 ],
1104 redaction_salt: None,
1105 })
1106 }
1107
1108 #[test]
1111 fn glob_exact_match() {
1112 assert!(glob_match("web-prod-1", "web-prod-1"));
1113 assert!(!glob_match("web-prod-1", "web-prod-2"));
1114 }
1115
1116 #[test]
1117 fn glob_star_suffix() {
1118 assert!(glob_match("web-*", "web-prod-1"));
1119 assert!(glob_match("web-*", "web-staging"));
1120 assert!(!glob_match("web-*", "api-prod"));
1121 }
1122
1123 #[test]
1124 fn glob_star_prefix() {
1125 assert!(glob_match("*-prod", "web-prod"));
1126 assert!(glob_match("*-prod", "api-prod"));
1127 assert!(!glob_match("*-prod", "web-staging"));
1128 }
1129
1130 #[test]
1131 fn glob_star_middle() {
1132 assert!(glob_match("web-*-prod", "web-us-prod"));
1133 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1134 assert!(!glob_match("web-*-prod", "web-staging"));
1135 }
1136
1137 #[test]
1138 fn glob_star_only() {
1139 assert!(glob_match("*", "anything"));
1140 assert!(glob_match("*", ""));
1141 }
1142
1143 #[test]
1144 fn glob_multiple_stars() {
1145 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1146 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1147 }
1148
1149 #[test]
1154 fn glob_match_multibyte_utf8() {
1155 assert!(glob_match("hé*llo", "héllo"));
1156 assert!(glob_match("*ö*", "wörld"));
1157 assert!(glob_match("über*", "übermensch"));
1158 assert!(glob_match("*界", "世界"));
1159 assert!(!glob_match("hé*llo", "hello"));
1160 assert!(!glob_match("界*", "世界"));
1161 assert!(glob_match("世*界", "世界"));
1162 }
1163
1164 #[test]
1176 fn glob_prefix_and_suffix_meet_exactly() {
1177 assert!(glob_match("ab*cd", "abcd"));
1180 }
1181
1182 #[test]
1187 fn glob_middle_segment_required_with_suffix() {
1188 assert!(!glob_match("a*b*c", "axyc"));
1193 }
1194
1195 #[test]
1201 fn glob_match_middle_advances_past_matched_part() {
1202 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1207 }
1208
1209 #[test]
1214 fn glob_match_middle_uses_addition_not_multiplication() {
1215 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1219 }
1220
1221 #[test]
1230 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1231 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1239 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1240 "run-*",
1241 "cmd",
1242 vec!["ls".into()],
1243 )]);
1244 let mut config = RbacConfig::with_roles(vec![role]);
1245 config.enabled = true;
1246 let policy = RbacPolicy::new(&config);
1247 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1248 }
1249
1250 #[test]
1253 fn disabled_policy_allows_everything() {
1254 let policy = RbacPolicy::new(&RbacConfig {
1255 enabled: false,
1256 roles: vec![],
1257 redaction_salt: None,
1258 });
1259 assert_eq!(
1260 policy.check("nonexistent", "resource_delete", "any-host"),
1261 RbacDecision::Allow
1262 );
1263 }
1264
1265 #[test]
1266 fn unknown_role_denied() {
1267 let policy = test_policy();
1268 assert_eq!(
1269 policy.check("unknown", "resource_list", "web-prod-1"),
1270 RbacDecision::Deny
1271 );
1272 }
1273
1274 #[test]
1275 fn viewer_allowed_read_ops() {
1276 let policy = test_policy();
1277 assert_eq!(
1278 policy.check("viewer", "resource_list", "web-prod-1"),
1279 RbacDecision::Allow
1280 );
1281 assert_eq!(
1282 policy.check("viewer", "system_info", "db-host"),
1283 RbacDecision::Allow
1284 );
1285 }
1286
1287 #[test]
1288 fn viewer_denied_write_ops() {
1289 let policy = test_policy();
1290 assert_eq!(
1291 policy.check("viewer", "resource_run", "web-prod-1"),
1292 RbacDecision::Deny
1293 );
1294 assert_eq!(
1295 policy.check("viewer", "resource_delete", "web-prod-1"),
1296 RbacDecision::Deny
1297 );
1298 }
1299
1300 #[test]
1301 fn deploy_allowed_on_matching_hosts() {
1302 let policy = test_policy();
1303 assert_eq!(
1304 policy.check("deploy", "resource_run", "web-prod-1"),
1305 RbacDecision::Allow
1306 );
1307 assert_eq!(
1308 policy.check("deploy", "resource_start", "api-staging"),
1309 RbacDecision::Allow
1310 );
1311 }
1312
1313 #[test]
1314 fn deploy_denied_on_non_matching_host() {
1315 let policy = test_policy();
1316 assert_eq!(
1317 policy.check("deploy", "resource_run", "db-prod-1"),
1318 RbacDecision::Deny
1319 );
1320 }
1321
1322 #[test]
1323 fn deny_overrides_allow() {
1324 let policy = test_policy();
1325 assert_eq!(
1326 policy.check("deploy", "resource_delete", "web-prod-1"),
1327 RbacDecision::Deny
1328 );
1329 assert_eq!(
1330 policy.check("deploy", "resource_exec", "web-prod-1"),
1331 RbacDecision::Deny
1332 );
1333 }
1334
1335 #[test]
1336 fn ops_wildcard_allows_everything() {
1337 let policy = test_policy();
1338 assert_eq!(
1339 policy.check("ops", "resource_delete", "any-host"),
1340 RbacDecision::Allow
1341 );
1342 assert_eq!(
1343 policy.check("ops", "secret_create", "db-host"),
1344 RbacDecision::Allow
1345 );
1346 }
1347
1348 #[test]
1351 fn host_visible_respects_globs() {
1352 let policy = test_policy();
1353 assert!(policy.host_visible("deploy", "web-prod-1"));
1354 assert!(policy.host_visible("deploy", "api-staging"));
1355 assert!(!policy.host_visible("deploy", "db-prod-1"));
1356 assert!(policy.host_visible("ops", "anything"));
1357 assert!(policy.host_visible("viewer", "anything"));
1358 }
1359
1360 #[test]
1361 fn host_visible_unknown_role() {
1362 let policy = test_policy();
1363 assert!(!policy.host_visible("unknown", "web-prod-1"));
1364 }
1365
1366 #[test]
1369 fn argument_allowed_no_allowlist() {
1370 let policy = test_policy();
1371 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1373 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1374 }
1375
1376 #[test]
1377 fn argument_allowed_with_allowlist() {
1378 let policy = test_policy();
1379 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1380 assert!(policy.argument_allowed(
1381 "restricted-exec",
1382 "resource_exec",
1383 "cmd",
1384 "bash -c 'echo hi'"
1385 ));
1386 assert!(policy.argument_allowed(
1387 "restricted-exec",
1388 "resource_exec",
1389 "cmd",
1390 "cat /etc/hosts"
1391 ));
1392 assert!(policy.argument_allowed(
1393 "restricted-exec",
1394 "resource_exec",
1395 "cmd",
1396 "/usr/bin/ls -la"
1397 ));
1398 }
1399
1400 #[test]
1401 fn argument_denied_not_in_allowlist() {
1402 let policy = test_policy();
1403 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1404 assert!(!policy.argument_allowed(
1405 "restricted-exec",
1406 "resource_exec",
1407 "cmd",
1408 "python3 exploit.py"
1409 ));
1410 assert!(!policy.argument_allowed(
1411 "restricted-exec",
1412 "resource_exec",
1413 "cmd",
1414 "/usr/bin/curl evil.com"
1415 ));
1416 }
1417
1418 #[test]
1419 fn argument_denied_unknown_role() {
1420 let policy = test_policy();
1421 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1422 }
1423
1424 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1433 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1434 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1435 let mut config = RbacConfig::with_roles(vec![role]);
1436 config.enabled = true;
1437 RbacPolicy::new(&config)
1438 }
1439
1440 #[test]
1441 fn argument_allowed_matches_quoted_path_with_spaces() {
1442 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1443 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1444 }
1445
1446 #[test]
1447 fn argument_allowed_matches_basename_of_quoted_path() {
1448 let policy = shlex_policy(vec!["my tool".into()]);
1449 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1450 }
1451
1452 #[test]
1453 fn argument_allowed_fails_closed_on_unbalanced_quote() {
1454 let policy = shlex_policy(vec!["unbalanced".into()]);
1455 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1456 }
1457
1458 #[test]
1459 fn argument_allowed_fails_closed_on_empty_string() {
1460 let policy = shlex_policy(vec![String::new()]);
1461 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1462 }
1463
1464 #[test]
1465 fn argument_allowed_handles_single_quoted_executable() {
1466 let policy = shlex_policy(vec!["/bin/sh".into()]);
1467 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1468 }
1469
1470 #[test]
1471 fn argument_allowed_handles_tab_separator() {
1472 let policy = shlex_policy(vec!["ls".into()]);
1473 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1474 }
1475
1476 #[test]
1477 fn argument_allowed_plain_token_unchanged() {
1478 let policy = shlex_policy(vec!["ls".into()]);
1479 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1480 }
1481
1482 #[test]
1488 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1489 let policy = shlex_policy(vec![String::new()]);
1493 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1494 }
1495
1496 #[test]
1497 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1498 let policy = shlex_policy(vec!["'bash'".into()]);
1504 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1505 }
1506
1507 #[test]
1508 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1509 let policy = shlex_policy(vec![r"foo\bar".into()]);
1514 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1515 }
1516
1517 #[test]
1518 fn argument_allowed_windows_path_no_longer_matches() {
1519 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1524 assert!(!policy.argument_allowed(
1525 "viewer",
1526 "run",
1527 "cmd",
1528 r"C:\Windows\System32\cmd.exe /c dir"
1529 ));
1530 }
1531
1532 #[test]
1535 fn host_patterns_returns_globs() {
1536 let policy = test_policy();
1537 assert_eq!(
1538 policy.host_patterns("deploy"),
1539 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1540 );
1541 assert_eq!(
1542 policy.host_patterns("ops"),
1543 Some(vec!["*".to_owned()].as_slice())
1544 );
1545 assert!(policy.host_patterns("nonexistent").is_none());
1546 }
1547
1548 #[test]
1551 fn check_operation_allows_without_host() {
1552 let policy = test_policy();
1553 assert_eq!(
1554 policy.check_operation("deploy", "resource_run"),
1555 RbacDecision::Allow
1556 );
1557 assert_eq!(
1559 policy.check("deploy", "resource_run", "db-prod-1"),
1560 RbacDecision::Deny
1561 );
1562 }
1563
1564 #[test]
1565 fn check_operation_deny_overrides() {
1566 let policy = test_policy();
1567 assert_eq!(
1568 policy.check_operation("deploy", "resource_delete"),
1569 RbacDecision::Deny
1570 );
1571 }
1572
1573 #[test]
1574 fn check_operation_unknown_role() {
1575 let policy = test_policy();
1576 assert_eq!(
1577 policy.check_operation("unknown", "resource_list"),
1578 RbacDecision::Deny
1579 );
1580 }
1581
1582 #[test]
1583 fn check_operation_disabled() {
1584 let policy = RbacPolicy::new(&RbacConfig {
1585 enabled: false,
1586 roles: vec![],
1587 redaction_salt: None,
1588 });
1589 assert_eq!(
1590 policy.check_operation("nonexistent", "anything"),
1591 RbacDecision::Allow
1592 );
1593 }
1594
1595 #[test]
1598 fn current_role_returns_none_outside_scope() {
1599 assert!(current_role().is_none());
1600 }
1601
1602 #[test]
1603 fn current_identity_returns_none_outside_scope() {
1604 assert!(current_identity().is_none());
1605 }
1606
1607 use axum::{
1610 body::Body,
1611 http::{Method, Request, StatusCode},
1612 };
1613 use tower::ServiceExt as _;
1614
1615 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1616 serde_json::json!({
1617 "jsonrpc": "2.0",
1618 "id": 1,
1619 "method": "tools/call",
1620 "params": {
1621 "name": tool,
1622 "arguments": args
1623 }
1624 })
1625 .to_string()
1626 }
1627
1628 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1629 axum::Router::new()
1630 .route("/mcp", axum::routing::post(|| async { "ok" }))
1631 .layer(axum::middleware::from_fn(move |req, next| {
1632 let p = Arc::clone(&policy);
1633 rbac_middleware(p, None, req, next)
1634 }))
1635 }
1636
1637 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1638 axum::Router::new()
1639 .route("/mcp", axum::routing::post(|| async { "ok" }))
1640 .layer(axum::middleware::from_fn(
1641 move |mut req: Request<Body>, next: Next| {
1642 let p = Arc::clone(&policy);
1643 let id = identity.clone();
1644 async move {
1645 req.extensions_mut().insert(id);
1646 rbac_middleware(p, None, req, next).await
1647 }
1648 },
1649 ))
1650 }
1651
1652 #[tokio::test]
1653 async fn middleware_passes_non_post() {
1654 let policy = Arc::new(test_policy());
1655 let app = rbac_router(policy);
1656 let req = Request::builder()
1658 .method(Method::GET)
1659 .uri("/mcp")
1660 .body(Body::empty())
1661 .unwrap();
1662 let resp = app.oneshot(req).await.unwrap();
1665 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1666 }
1667
1668 #[tokio::test]
1669 async fn middleware_denies_without_identity() {
1670 let policy = Arc::new(test_policy());
1671 let app = rbac_router(policy);
1672 let body = tool_call_body("resource_list", &serde_json::json!({}));
1673 let req = Request::builder()
1674 .method(Method::POST)
1675 .uri("/mcp")
1676 .header("content-type", "application/json")
1677 .body(Body::from(body))
1678 .unwrap();
1679 let resp = app.oneshot(req).await.unwrap();
1680 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1681 }
1682
1683 #[tokio::test]
1684 async fn middleware_allows_permitted_tool() {
1685 let policy = Arc::new(test_policy());
1686 let id = AuthIdentity {
1687 method: crate::auth::AuthMethod::BearerToken,
1688 name: "alice".into(),
1689 role: "viewer".into(),
1690 raw_token: None,
1691 sub: None,
1692 };
1693 let app = rbac_router_with_identity(policy, id);
1694 let body = tool_call_body("resource_list", &serde_json::json!({}));
1695 let req = Request::builder()
1696 .method(Method::POST)
1697 .uri("/mcp")
1698 .header("content-type", "application/json")
1699 .body(Body::from(body))
1700 .unwrap();
1701 let resp = app.oneshot(req).await.unwrap();
1702 assert_eq!(resp.status(), StatusCode::OK);
1703 }
1704
1705 #[tokio::test]
1706 async fn middleware_denies_unpermitted_tool() {
1707 let policy = Arc::new(test_policy());
1708 let id = AuthIdentity {
1709 method: crate::auth::AuthMethod::BearerToken,
1710 name: "alice".into(),
1711 role: "viewer".into(),
1712 raw_token: None,
1713 sub: None,
1714 };
1715 let app = rbac_router_with_identity(policy, id);
1716 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1717 let req = Request::builder()
1718 .method(Method::POST)
1719 .uri("/mcp")
1720 .header("content-type", "application/json")
1721 .body(Body::from(body))
1722 .unwrap();
1723 let resp = app.oneshot(req).await.unwrap();
1724 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1725 }
1726
1727 #[tokio::test]
1728 async fn middleware_passes_non_tool_call_post() {
1729 let policy = Arc::new(test_policy());
1730 let id = AuthIdentity {
1731 method: crate::auth::AuthMethod::BearerToken,
1732 name: "alice".into(),
1733 role: "viewer".into(),
1734 raw_token: None,
1735 sub: None,
1736 };
1737 let app = rbac_router_with_identity(policy, id);
1738 let body = serde_json::json!({
1740 "jsonrpc": "2.0",
1741 "id": 1,
1742 "method": "resources/list"
1743 })
1744 .to_string();
1745 let req = Request::builder()
1746 .method(Method::POST)
1747 .uri("/mcp")
1748 .header("content-type", "application/json")
1749 .body(Body::from(body))
1750 .unwrap();
1751 let resp = app.oneshot(req).await.unwrap();
1752 assert_eq!(resp.status(), StatusCode::OK);
1753 }
1754
1755 #[tokio::test]
1756 async fn middleware_enforces_argument_allowlist() {
1757 let policy = Arc::new(test_policy());
1758 let id = AuthIdentity {
1759 method: crate::auth::AuthMethod::BearerToken,
1760 name: "dev".into(),
1761 role: "restricted-exec".into(),
1762 raw_token: None,
1763 sub: None,
1764 };
1765 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1767 let body = tool_call_body(
1768 "resource_exec",
1769 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1770 );
1771 let req = Request::builder()
1772 .method(Method::POST)
1773 .uri("/mcp")
1774 .body(Body::from(body))
1775 .unwrap();
1776 let resp = app.oneshot(req).await.unwrap();
1777 assert_eq!(resp.status(), StatusCode::OK);
1778
1779 let app = rbac_router_with_identity(policy, id);
1781 let body = tool_call_body(
1782 "resource_exec",
1783 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1784 );
1785 let req = Request::builder()
1786 .method(Method::POST)
1787 .uri("/mcp")
1788 .body(Body::from(body))
1789 .unwrap();
1790 let resp = app.oneshot(req).await.unwrap();
1791 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1792 }
1793
1794 #[tokio::test]
1795 async fn middleware_disabled_policy_passes_everything() {
1796 let policy = Arc::new(RbacPolicy::disabled());
1797 let app = rbac_router(policy);
1798 let body = tool_call_body("anything", &serde_json::json!({}));
1800 let req = Request::builder()
1801 .method(Method::POST)
1802 .uri("/mcp")
1803 .body(Body::from(body))
1804 .unwrap();
1805 let resp = app.oneshot(req).await.unwrap();
1806 assert_eq!(resp.status(), StatusCode::OK);
1807 }
1808
1809 #[tokio::test]
1810 async fn middleware_batch_all_allowed_passes() {
1811 let policy = Arc::new(test_policy());
1812 let id = AuthIdentity {
1813 method: crate::auth::AuthMethod::BearerToken,
1814 name: "alice".into(),
1815 role: "viewer".into(),
1816 raw_token: None,
1817 sub: None,
1818 };
1819 let app = rbac_router_with_identity(policy, id);
1820 let body = serde_json::json!([
1821 {
1822 "jsonrpc": "2.0",
1823 "id": 1,
1824 "method": "tools/call",
1825 "params": { "name": "resource_list", "arguments": {} }
1826 },
1827 {
1828 "jsonrpc": "2.0",
1829 "id": 2,
1830 "method": "tools/call",
1831 "params": { "name": "system_info", "arguments": {} }
1832 }
1833 ])
1834 .to_string();
1835 let req = Request::builder()
1836 .method(Method::POST)
1837 .uri("/mcp")
1838 .header("content-type", "application/json")
1839 .body(Body::from(body))
1840 .unwrap();
1841 let resp = app.oneshot(req).await.unwrap();
1842 assert_eq!(resp.status(), StatusCode::OK);
1843 }
1844
1845 #[tokio::test]
1846 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
1847 let policy = Arc::new(test_policy());
1848 let id = AuthIdentity {
1849 method: crate::auth::AuthMethod::BearerToken,
1850 name: "alice".into(),
1851 role: "viewer".into(),
1852 raw_token: None,
1853 sub: None,
1854 };
1855 let app = rbac_router_with_identity(policy, id);
1856 let body = serde_json::json!([
1857 {
1858 "jsonrpc": "2.0",
1859 "id": 1,
1860 "method": "tools/call",
1861 "params": { "name": "resource_list", "arguments": {} }
1862 },
1863 {
1864 "jsonrpc": "2.0",
1865 "id": 2,
1866 "method": "tools/call",
1867 "params": { "name": "resource_delete", "arguments": {} }
1868 }
1869 ])
1870 .to_string();
1871 let req = Request::builder()
1872 .method(Method::POST)
1873 .uri("/mcp")
1874 .header("content-type", "application/json")
1875 .body(Body::from(body))
1876 .unwrap();
1877 let resp = app.oneshot(req).await.unwrap();
1878 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1879 }
1880
1881 #[tokio::test]
1882 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1883 let policy = Arc::new(test_policy());
1884 let id = AuthIdentity {
1885 method: crate::auth::AuthMethod::BearerToken,
1886 name: "dev".into(),
1887 role: "restricted-exec".into(),
1888 raw_token: None,
1889 sub: None,
1890 };
1891 let app = rbac_router_with_identity(policy, id);
1892 let body = serde_json::json!([
1893 {
1894 "jsonrpc": "2.0",
1895 "id": 1,
1896 "method": "tools/call",
1897 "params": {
1898 "name": "resource_exec",
1899 "arguments": { "cmd": "ls -la", "host": "dev-1" }
1900 }
1901 },
1902 {
1903 "jsonrpc": "2.0",
1904 "id": 2,
1905 "method": "tools/call",
1906 "params": {
1907 "name": "resource_exec",
1908 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1909 }
1910 }
1911 ])
1912 .to_string();
1913 let req = Request::builder()
1914 .method(Method::POST)
1915 .uri("/mcp")
1916 .header("content-type", "application/json")
1917 .body(Body::from(body))
1918 .unwrap();
1919 let resp = app.oneshot(req).await.unwrap();
1920 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1921 }
1922
1923 #[test]
1926 fn redact_with_salt_is_deterministic_per_salt() {
1927 let salt = b"unit-test-salt";
1928 let a = redact_with_salt(salt, "rm -rf /");
1929 let b = redact_with_salt(salt, "rm -rf /");
1930 assert_eq!(a, b, "same input + salt must yield identical hash");
1931 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1932 assert!(
1933 a.chars().all(|c| c.is_ascii_hexdigit()),
1934 "redacted hash must be lowercase hex: {a}"
1935 );
1936 }
1937
1938 #[test]
1939 fn redact_with_salt_differs_across_salts() {
1940 let v = "the-same-value";
1941 let h1 = redact_with_salt(b"salt-one", v);
1942 let h2 = redact_with_salt(b"salt-two", v);
1943 assert_ne!(
1944 h1, h2,
1945 "different salts must produce different hashes for the same value"
1946 );
1947 }
1948
1949 #[test]
1950 fn redact_with_salt_distinguishes_values() {
1951 let salt = b"k";
1952 let h1 = redact_with_salt(salt, "alpha");
1953 let h2 = redact_with_salt(salt, "beta");
1954 assert_ne!(h1, h2, "different values must produce different hashes");
1956 }
1957
1958 #[test]
1959 fn policy_with_configured_salt_redacts_consistently() {
1960 let cfg = RbacConfig {
1961 enabled: true,
1962 roles: vec![],
1963 redaction_salt: Some(SecretString::from("my-stable-salt")),
1964 };
1965 let p1 = RbacPolicy::new(&cfg);
1966 let p2 = RbacPolicy::new(&cfg);
1967 assert_eq!(
1968 p1.redact_arg("payload"),
1969 p2.redact_arg("payload"),
1970 "policies built from the same configured salt must agree"
1971 );
1972 }
1973
1974 #[test]
1975 fn policy_without_configured_salt_uses_process_salt() {
1976 let cfg = RbacConfig {
1977 enabled: true,
1978 roles: vec![],
1979 redaction_salt: None,
1980 };
1981 let p1 = RbacPolicy::new(&cfg);
1982 let p2 = RbacPolicy::new(&cfg);
1983 assert_eq!(
1985 p1.redact_arg("payload"),
1986 p2.redact_arg("payload"),
1987 "process-wide salt must be consistent within one process"
1988 );
1989 }
1990
1991 #[test]
1992 fn redact_arg_is_fast_enough() {
1993 let salt = b"perf-sanity-salt-32-bytes-padded";
1997 let value = "x".repeat(256);
1998 let start = std::time::Instant::now();
1999 let _ = redact_with_salt(salt, &value);
2000 let elapsed = start.elapsed();
2001 assert!(
2002 elapsed < Duration::from_millis(5),
2003 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
2004 );
2005 }
2006
2007 #[tokio::test]
2019 async fn deny_path_uses_explicit_identity_not_task_local() {
2020 let policy = Arc::new(test_policy());
2021 let id = AuthIdentity {
2022 method: crate::auth::AuthMethod::BearerToken,
2023 name: "alice-the-auditor".into(),
2024 role: "viewer".into(),
2025 raw_token: None,
2026 sub: None,
2027 };
2028 let app = rbac_router_with_identity(policy, id);
2029 let body = tool_call_body("resource_delete", &serde_json::json!({}));
2031 let req = Request::builder()
2032 .method(Method::POST)
2033 .uri("/mcp")
2034 .header("content-type", "application/json")
2035 .body(Body::from(body))
2036 .unwrap();
2037 let resp = app.oneshot(req).await.unwrap();
2038 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2039 }
2040
2041 fn restricted_exec_identity() -> AuthIdentity {
2044 AuthIdentity {
2045 method: crate::auth::AuthMethod::BearerToken,
2046 name: "carol".into(),
2047 role: "restricted-exec".into(),
2048 raw_token: None,
2049 sub: None,
2050 }
2051 }
2052
2053 #[test]
2054 fn has_argument_allowlist_matches_configured_tool_argument() {
2055 let policy = test_policy();
2056 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2057 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2058 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2059 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2060 }
2061
2062 #[tokio::test]
2063 async fn array_arg_with_matching_allowlist_is_denied() {
2064 let policy = Arc::new(test_policy());
2065 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2066 let body = tool_call_body(
2067 "resource_exec",
2068 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2069 );
2070 let req = Request::builder()
2071 .method(Method::POST)
2072 .uri("/mcp")
2073 .header("content-type", "application/json")
2074 .body(Body::from(body))
2075 .unwrap();
2076 let resp = app.oneshot(req).await.unwrap();
2077 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2078 }
2079
2080 #[tokio::test]
2081 async fn object_arg_with_matching_allowlist_is_denied() {
2082 let policy = Arc::new(test_policy());
2083 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2084 let body = tool_call_body(
2085 "resource_exec",
2086 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2087 );
2088 let req = Request::builder()
2089 .method(Method::POST)
2090 .uri("/mcp")
2091 .header("content-type", "application/json")
2092 .body(Body::from(body))
2093 .unwrap();
2094 let resp = app.oneshot(req).await.unwrap();
2095 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2096 }
2097
2098 #[tokio::test]
2099 async fn number_arg_with_matching_allowlist_is_denied() {
2100 let policy = Arc::new(test_policy());
2101 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2102 let body = tool_call_body(
2103 "resource_exec",
2104 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2105 );
2106 let req = Request::builder()
2107 .method(Method::POST)
2108 .uri("/mcp")
2109 .header("content-type", "application/json")
2110 .body(Body::from(body))
2111 .unwrap();
2112 let resp = app.oneshot(req).await.unwrap();
2113 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2114 }
2115
2116 #[tokio::test]
2117 async fn bool_arg_with_matching_allowlist_is_denied() {
2118 let policy = Arc::new(test_policy());
2119 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2120 let body = tool_call_body(
2121 "resource_exec",
2122 &serde_json::json!({ "host": "dev-1", "cmd": true }),
2123 );
2124 let req = Request::builder()
2125 .method(Method::POST)
2126 .uri("/mcp")
2127 .header("content-type", "application/json")
2128 .body(Body::from(body))
2129 .unwrap();
2130 let resp = app.oneshot(req).await.unwrap();
2131 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2132 }
2133
2134 #[tokio::test]
2135 async fn null_arg_with_matching_allowlist_is_denied() {
2136 let policy = Arc::new(test_policy());
2137 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2138 let body = tool_call_body(
2139 "resource_exec",
2140 &serde_json::json!({ "host": "dev-1", "cmd": null }),
2141 );
2142 let req = Request::builder()
2143 .method(Method::POST)
2144 .uri("/mcp")
2145 .header("content-type", "application/json")
2146 .body(Body::from(body))
2147 .unwrap();
2148 let resp = app.oneshot(req).await.unwrap();
2149 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2150 }
2151
2152 #[tokio::test]
2153 async fn non_string_arg_without_allowlist_is_passthrough() {
2154 let policy = Arc::new(test_policy());
2158 let id = AuthIdentity {
2159 method: crate::auth::AuthMethod::BearerToken,
2160 name: "olivia".into(),
2161 role: "ops".into(),
2162 raw_token: None,
2163 sub: None,
2164 };
2165 let app = rbac_router_with_identity(policy, id);
2166 let body = tool_call_body(
2167 "resource_exec",
2168 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2169 );
2170 let req = Request::builder()
2171 .method(Method::POST)
2172 .uri("/mcp")
2173 .header("content-type", "application/json")
2174 .body(Body::from(body))
2175 .unwrap();
2176 let resp = app.oneshot(req).await.unwrap();
2177 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2178 }
2179
2180 #[tokio::test]
2181 async fn string_arg_in_allowlist_still_passes() {
2182 let policy = Arc::new(test_policy());
2183 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2184 let body = tool_call_body(
2185 "resource_exec",
2186 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2187 );
2188 let req = Request::builder()
2189 .method(Method::POST)
2190 .uri("/mcp")
2191 .header("content-type", "application/json")
2192 .body(Body::from(body))
2193 .unwrap();
2194 let resp = app.oneshot(req).await.unwrap();
2195 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2196 }
2197}