1use std::{net::IpAddr, num::NonZeroU32, path::PathBuf, 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::RmcpServerKitError};
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]
125pub fn current_token() -> Option<SecretString> {
126 CURRENT_TOKEN
127 .try_with(|t| {
128 if t.expose_secret().is_empty() {
129 None
130 } else {
131 Some(t.clone())
132 }
133 })
134 .ok()
135 .flatten()
136}
137
138#[must_use]
142pub fn current_sub() -> Option<String> {
143 CURRENT_SUB
144 .try_with(Clone::clone)
145 .ok()
146 .filter(|s| !s.is_empty())
147}
148
149pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
156 CURRENT_TOKEN.scope(token, f).await
157}
158
159pub 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)]
261#[non_exhaustive]
262pub struct ArgumentAllowlist {
263 pub tool: String,
265 pub argument: String,
267 #[serde(default)]
269 pub allowed: Vec<String>,
270 #[serde(default)]
283 pub required: bool,
284}
285
286impl ArgumentAllowlist {
287 #[must_use]
292 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
293 Self {
294 tool: tool.into(),
295 argument: argument.into(),
296 allowed,
297 required: false,
298 }
299 }
300
301 #[must_use]
303 pub const fn with_required(mut self, required: bool) -> Self {
304 self.required = required;
305 self
306 }
307}
308
309fn default_hosts() -> Vec<String> {
310 vec!["*".into()]
311}
312
313#[derive(Debug, Clone, Default, Deserialize)]
315#[non_exhaustive]
316pub struct RbacConfig {
317 #[serde(default)]
319 pub enabled: bool,
320 #[serde(default)]
322 pub roles: Vec<RoleConfig>,
323 #[serde(default)]
332 pub redaction_salt: Option<SecretString>,
333}
334
335impl RbacConfig {
336 #[must_use]
338 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
339 Self {
340 enabled: true,
341 roles,
342 redaction_salt: None,
343 }
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349#[non_exhaustive]
350pub enum RbacDecision {
351 Allow,
353 Deny,
355}
356
357#[derive(Debug, Clone, serde::Serialize)]
359#[non_exhaustive]
360pub struct RbacRoleSummary {
361 pub name: String,
363 pub allow: usize,
365 pub deny: usize,
367 pub hosts: usize,
369 pub argument_allowlists: usize,
371}
372
373#[derive(Debug, Clone, serde::Serialize)]
375#[non_exhaustive]
376pub struct RbacPolicySummary {
377 pub enabled: bool,
379 pub roles: Vec<RbacRoleSummary>,
381}
382
383#[derive(Debug, Clone)]
389#[non_exhaustive]
390pub struct RbacPolicy {
391 roles: Vec<RoleConfig>,
392 enabled: bool,
393 redaction_salt: Arc<SecretString>,
396}
397
398impl RbacPolicy {
399 #[must_use]
402 pub fn new(config: &RbacConfig) -> Self {
403 let salt = config
404 .redaction_salt
405 .clone()
406 .unwrap_or_else(|| process_redaction_salt().clone());
407 Self {
408 roles: config.roles.clone(),
409 enabled: config.enabled,
410 redaction_salt: Arc::new(salt),
411 }
412 }
413
414 #[must_use]
416 pub fn disabled() -> Self {
417 Self {
418 roles: Vec::new(),
419 enabled: false,
420 redaction_salt: Arc::new(process_redaction_salt().clone()),
421 }
422 }
423
424 #[must_use]
426 pub fn is_enabled(&self) -> bool {
427 self.enabled
428 }
429
430 #[must_use]
435 pub fn summary(&self) -> RbacPolicySummary {
436 let roles = self
437 .roles
438 .iter()
439 .map(|r| RbacRoleSummary {
440 name: r.name.clone(),
441 allow: r.allow.len(),
442 deny: r.deny.len(),
443 hosts: r.hosts.len(),
444 argument_allowlists: r.argument_allowlists.len(),
445 })
446 .collect();
447 RbacPolicySummary {
448 enabled: self.enabled,
449 roles,
450 }
451 }
452
453 #[must_use]
458 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
459 if !self.enabled {
460 return RbacDecision::Allow;
461 }
462 let Some(role_cfg) = self.find_role(role) else {
463 return RbacDecision::Deny;
464 };
465 if role_cfg.deny.iter().any(|d| d == operation) {
466 return RbacDecision::Deny;
467 }
468 if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
469 return RbacDecision::Allow;
470 }
471 RbacDecision::Deny
472 }
473
474 #[must_use]
481 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
482 if !self.enabled {
483 return RbacDecision::Allow;
484 }
485 let Some(role_cfg) = self.find_role(role) else {
486 return RbacDecision::Deny;
487 };
488 if role_cfg.deny.iter().any(|d| d == operation) {
489 return RbacDecision::Deny;
490 }
491 if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
492 return RbacDecision::Deny;
493 }
494 if !Self::host_matches(&role_cfg.hosts, host) {
495 return RbacDecision::Deny;
496 }
497 RbacDecision::Allow
498 }
499
500 #[must_use]
502 pub fn host_visible(&self, role: &str, host: &str) -> bool {
503 if !self.enabled {
504 return true;
505 }
506 let Some(role_cfg) = self.find_role(role) else {
507 return false;
508 };
509 Self::host_matches(&role_cfg.hosts, host)
510 }
511
512 #[must_use]
514 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
515 self.find_role(role).map(|r| r.hosts.as_slice())
516 }
517
518 #[must_use]
547 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
548 if !self.enabled {
549 return true;
550 }
551 let Some(role_cfg) = self.find_role(role) else {
552 return false;
553 };
554 for al in &role_cfg.argument_allowlists {
555 if al.tool != tool && !glob_match(&al.tool, tool) {
556 continue;
557 }
558 if al.argument != argument {
559 continue;
560 }
561 if al.allowed.is_empty() {
562 continue;
563 }
564 let Some(tokens) = shlex::split(value) else {
569 return false;
570 };
571 let Some(first_token) = tokens.first() else {
572 return false;
573 };
574 if first_token.is_empty() {
578 return false;
579 }
580 let basename = first_token
584 .rsplit('/')
585 .next()
586 .unwrap_or(first_token.as_str());
587 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
588 return false;
589 }
590 }
591 true
592 }
593
594 #[must_use]
604 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
605 if !self.enabled {
606 return false;
607 }
608 let Some(role_cfg) = self.find_role(role) else {
609 return false;
610 };
611 role_cfg.argument_allowlists.iter().any(|al| {
612 (al.tool == tool || glob_match(&al.tool, tool))
613 && al.argument == argument
614 && !al.allowed.is_empty()
615 })
616 }
617
618 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
620 self.roles.iter().find(|r| r.name == name)
621 }
622
623 fn missing_required_argument(
634 &self,
635 role: &str,
636 tool: &str,
637 args: Option<&serde_json::Map<String, serde_json::Value>>,
638 ) -> Option<&str> {
639 if !self.enabled {
640 return None;
641 }
642 let role_cfg = self.find_role(role)?;
643 role_cfg
644 .argument_allowlists
645 .iter()
646 .filter(|al| al.required)
647 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
651 .find(|al| {
652 !args.is_some_and(|a| {
653 a.get(&al.argument)
654 .is_some_and(serde_json::Value::is_string)
655 })
656 })
657 .map(|al| al.argument.as_str())
658 }
659
660 fn host_matches(patterns: &[String], host: &str) -> bool {
662 patterns.iter().any(|p| glob_match(p, host))
663 }
664
665 #[must_use]
674 pub fn redact_arg(&self, value: &str) -> String {
675 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
676 }
677}
678
679fn process_redaction_salt() -> &'static SecretString {
682 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
683 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
684 PROCESS_SALT.get_or_init(|| {
685 let mut bytes = [0u8; 32];
686 rand::fill(&mut bytes);
687 SecretString::from(STANDARD_NO_PAD.encode(bytes))
690 })
691}
692
693fn redact_with_salt(salt: &[u8], value: &str) -> String {
698 use std::fmt::Write as _;
699
700 use sha2::Digest as _;
701
702 type HmacSha256 = Hmac<Sha256>;
703 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
709 m
710 } else {
711 let digest = Sha256::digest(salt);
712 #[allow(
713 clippy::expect_used,
714 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
715 )]
716 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
717 };
718 mac.update(value.as_bytes());
719 let bytes = mac.finalize().into_bytes();
720 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
722 let mut out = String::with_capacity(8);
723 for b in prefix {
724 let _ = write!(out, "{b:02x}");
725 }
726 out
727}
728
729#[allow(
750 clippy::too_many_lines,
751 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
752)]
753pub(crate) async fn rbac_middleware(
754 policy: Arc<RbacPolicy>,
755 tool_limiter: Option<Arc<ToolRateLimiter>>,
756 req: Request<Body>,
757 next: Next,
758) -> Response {
759 if req.method() != Method::POST {
761 return next.run(req).await;
762 }
763
764 let peer_ip: Option<IpAddr> = crate::transport::limiter_client_ip(req.extensions());
767
768 let identity = req.extensions().get::<AuthIdentity>();
770 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
771 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
772 let raw_token: SecretString = identity
775 .and_then(|id| id.raw_token.clone())
776 .unwrap_or_else(|| SecretString::from(String::new()));
777 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
778
779 if policy.is_enabled() && identity.is_none() {
781 return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
782 }
783
784 let (parts, body) = req.into_parts();
786 let bytes = match body.collect().await {
787 Ok(collected) => collected.to_bytes(),
788 Err(e) => {
789 tracing::error!(error = %e, "failed to read request body");
790 return (
791 StatusCode::INTERNAL_SERVER_ERROR,
792 "failed to read request body",
793 )
794 .into_response();
795 }
796 };
797
798 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
800 let tool_calls = extract_tool_calls(&json);
801 if !tool_calls.is_empty() {
802 for params in tool_calls {
803 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
804 #[cfg(feature = "metrics")]
805 crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
806 return resp;
807 }
808 if policy.is_enabled()
809 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
810 {
811 return resp;
812 }
813 }
814 }
815 }
816 let req = Request::from_parts(parts, Body::from(bytes));
820
821 if role.is_empty() {
823 next.run(req).await
824 } else {
825 CURRENT_ROLE
826 .scope(
827 role,
828 CURRENT_IDENTITY.scope(
829 identity_name,
830 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
831 ),
832 )
833 .await
834 }
835}
836
837fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
843 match value {
844 serde_json::Value::Object(map) => map
845 .get("method")
846 .and_then(serde_json::Value::as_str)
847 .filter(|method| *method == "tools/call")
848 .and_then(|_| map.get("params"))
849 .into_iter()
850 .collect(),
851 serde_json::Value::Array(items) => items
852 .iter()
853 .filter_map(|item| match item {
854 serde_json::Value::Object(map) => map
855 .get("method")
856 .and_then(serde_json::Value::as_str)
857 .filter(|method| *method == "tools/call")
858 .and_then(|_| map.get("params")),
859 serde_json::Value::Null
860 | serde_json::Value::Bool(_)
861 | serde_json::Value::Number(_)
862 | serde_json::Value::String(_)
863 | serde_json::Value::Array(_) => None,
864 })
865 .collect(),
866 serde_json::Value::Null
867 | serde_json::Value::Bool(_)
868 | serde_json::Value::Number(_)
869 | serde_json::Value::String(_) => Vec::new(),
870 }
871}
872
873fn enforce_rate_limit(
876 tool_limiter: Option<&ToolRateLimiter>,
877 peer_ip: Option<IpAddr>,
878) -> Option<Response> {
879 let limiter = tool_limiter?;
880 let ip = peer_ip?;
881 if let Err(wait) = limiter.check_key_wait(&ip) {
882 tracing::warn!(%ip, "tool invocation rate limited");
883 return Some(
884 RmcpServerKitError::RateLimitedFor {
885 message: "too many tool invocations".into(),
886 retry_after: wait,
887 }
888 .into_response(),
889 );
890 }
891 None
892}
893
894fn enforce_tool_policy(
903 policy: &RbacPolicy,
904 identity_name: &str,
905 role: &str,
906 params: &serde_json::Value,
907) -> Option<Response> {
908 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
909 let host_value = params.get("arguments").and_then(|a| a.get("host"));
910
911 if let Some(value) = host_value
919 && !value.is_string()
920 {
921 tracing::warn!(
922 user = %identity_name,
923 role = %role,
924 tool = tool_name,
925 value_type = json_value_type(value),
926 "non-string host argument rejected"
927 );
928 return Some(
929 RmcpServerKitError::Rbac(format!(
930 "argument 'host' must be a string for tool '{tool_name}'"
931 ))
932 .into_response(),
933 );
934 }
935 let host = host_value.and_then(|h| h.as_str());
938
939 let decision = if let Some(host) = host {
940 policy.check(role, tool_name, host)
941 } else {
942 policy.check_operation(role, tool_name)
943 };
944 if decision == RbacDecision::Deny {
945 tracing::warn!(
946 user = %identity_name,
947 role = %role,
948 tool = tool_name,
949 host = host.unwrap_or("-"),
950 "RBAC denied"
951 );
952 return Some(
953 RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
954 .into_response(),
955 );
956 }
957
958 let args = params.get("arguments").and_then(|a| a.as_object());
959 if let Some(args) = args {
960 for (arg_key, arg_val) in args {
961 if let Some(resp) =
962 check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
963 {
964 return Some(resp);
965 }
966 }
967 }
968 check_required_arguments(policy, identity_name, role, tool_name, args)
969}
970
971fn check_required_arguments(
979 policy: &RbacPolicy,
980 identity_name: &str,
981 role: &str,
982 tool_name: &str,
983 args: Option<&serde_json::Map<String, serde_json::Value>>,
984) -> Option<Response> {
985 let missing = policy.missing_required_argument(role, tool_name, args)?;
986 tracing::warn!(
987 user = %identity_name,
988 role = %role,
989 tool = tool_name,
990 argument = missing,
991 "required argument missing"
992 );
993 Some(
994 RmcpServerKitError::Rbac(format!(
995 "argument '{missing}' is required for tool '{tool_name}'"
996 ))
997 .into_response(),
998 )
999}
1000
1001fn check_argument(
1002 policy: &RbacPolicy,
1003 identity_name: &str,
1004 role: &str,
1005 tool_name: &str,
1006 arg_key: &str,
1007 arg_val: &serde_json::Value,
1008) -> Option<Response> {
1009 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1010 return None;
1011 }
1012 let Some(val_str) = arg_val.as_str() else {
1013 tracing::warn!(
1019 user = %identity_name,
1020 role = %role,
1021 tool = tool_name,
1022 argument = arg_key,
1023 value_type = json_value_type(arg_val),
1024 "non-string argument rejected by allowlist"
1025 );
1026 return Some(
1027 RmcpServerKitError::Rbac(format!(
1028 "argument '{arg_key}' must be a string for tool '{tool_name}'"
1029 ))
1030 .into_response(),
1031 );
1032 };
1033 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1034 return None;
1035 }
1036 tracing::warn!(
1041 user = %identity_name,
1042 role = %role,
1043 tool = tool_name,
1044 argument = arg_key,
1045 arg_hmac = %policy.redact_arg(val_str),
1046 "argument not in allowlist"
1047 );
1048 Some(
1049 RmcpServerKitError::Rbac(format!(
1050 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1051 ))
1052 .into_response(),
1053 )
1054}
1055
1056fn json_value_type(v: &serde_json::Value) -> &'static str {
1057 match v {
1058 serde_json::Value::Null => "null",
1059 serde_json::Value::Bool(_) => "bool",
1060 serde_json::Value::Number(_) => "number",
1061 serde_json::Value::String(_) => "string",
1062 serde_json::Value::Array(_) => "array",
1063 serde_json::Value::Object(_) => "object",
1064 }
1065}
1066
1067fn glob_match(pattern: &str, text: &str) -> bool {
1077 let parts: Vec<&str> = pattern.split('*').collect();
1078 if parts.len() == 1 {
1079 return pattern == text;
1081 }
1082
1083 let pos = if let Some(&first) = parts.first()
1085 && !first.is_empty()
1086 {
1087 if !text.starts_with(first) {
1088 return false;
1089 }
1090 first.len()
1091 } else {
1092 0
1093 };
1094
1095 if let Some(&last) = parts.last()
1097 && !last.is_empty()
1098 {
1099 if !text.get(pos..).unwrap_or_default().ends_with(last) {
1100 return false;
1101 }
1102 let end = text.len() - last.len();
1104 if pos > end {
1105 return false;
1106 }
1107 let middle = text.get(pos..end).unwrap_or_default();
1109 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1110 return match_middle(middle, middle_parts);
1111 }
1112
1113 let middle = text.get(pos..).unwrap_or_default();
1115 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1116 match_middle(middle, middle_parts)
1117}
1118
1119fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1121 for part in parts {
1122 if part.is_empty() {
1123 continue;
1124 }
1125 if let Some(idx) = text.find(part) {
1126 text = text.get(idx + part.len()..).unwrap_or_default();
1127 } else {
1128 return false;
1129 }
1130 }
1131 true
1132}
1133
1134impl RbacConfig {
1135 pub fn apply_env_overrides(
1168 &mut self,
1169 ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1170 let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1171 let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1172 match (direct, file) {
1173 (None, None) => Ok(Vec::new()),
1174 (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1175 "{} and {} must not both be set",
1176 crate::config::RBAC_REDACTION_SALT_ENV,
1177 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1178 ))),
1179 (Some(value), None) => {
1180 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1181 self.redaction_salt = Some(SecretString::from(value));
1182 Ok(vec![crate::config::secret_env_report(
1183 crate::config::RBAC_REDACTION_SALT_ENV,
1184 "rbac.redaction_salt",
1185 crate::config::EnvOverrideSource::Env,
1186 )])
1187 }
1188 (None, Some(path)) => {
1189 let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1190 RmcpServerKitError::Config(format!(
1191 "failed to read {} file {path:?}: {error}",
1192 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1193 ))
1194 })?;
1195 let secret = normalize_text_secret_file(secret);
1196 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1197 self.redaction_salt = Some(SecretString::from(secret));
1198 Ok(vec![crate::config::secret_env_report(
1199 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1200 "rbac.redaction_salt",
1201 crate::config::EnvOverrideSource::File,
1202 )])
1203 }
1204 }
1205 }
1206}
1207
1208fn normalize_text_secret_file(mut secret: String) -> String {
1209 if secret.ends_with("\r\n") {
1210 secret.truncate(secret.len() - 2);
1211 } else if secret.ends_with('\n') || secret.ends_with('\r') {
1212 secret.truncate(secret.len() - 1);
1213 }
1214 secret
1215}
1216
1217fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1218 if value.trim().is_empty() {
1219 return Err(RmcpServerKitError::Config(format!(
1220 "{env_var} must not be empty or whitespace-only"
1221 )));
1222 }
1223 Ok(())
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229
1230 fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1231 temp_env::with_vars(
1232 [
1233 (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1234 (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1235 ]
1236 .into_iter()
1237 .chain(vars.iter().copied())
1238 .collect::<Vec<_>>(),
1239 f,
1240 )
1241 }
1242
1243 #[test]
1244 fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1245 with_rbac_env(
1246 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1247 || {
1248 let mut cfg = RbacConfig::default();
1249 let report = cfg.apply_env_overrides().unwrap();
1250 assert!(cfg.redaction_salt.is_some());
1251 assert_eq!(report.len(), 1);
1252 assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1253 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1254 assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1255 assert!(report[0].value.is_none());
1256 assert!(!format!("{report:?}").contains("s3cret"));
1257 },
1258 );
1259 }
1260
1261 #[test]
1262 fn e7_redaction_salt_value_and_file_conflict_fails() {
1263 with_rbac_env(
1264 &[
1265 (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1266 (
1267 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1268 Some("/tmp/secret-file"),
1269 ),
1270 ],
1271 || {
1272 let mut cfg = RbacConfig::default();
1273 let err = cfg.apply_env_overrides().unwrap_err();
1274 let msg = err.to_string();
1275 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1276 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1277 },
1278 );
1279 }
1280
1281 #[test]
1282 fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1283 let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1284 let direct_redaction = redaction_from_direct_salt("same-salt");
1285
1286 assert_eq!(file_redaction, direct_redaction);
1287 assert_eq!(report.len(), 1);
1288 assert_eq!(
1289 report[0].env_var,
1290 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1291 );
1292 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1293 assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1294 assert!(report[0].value.is_none());
1295 }
1296
1297 #[test]
1298 fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1299 let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1300 assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1301
1302 let (spaced_redaction, _) = redaction_from_file(" same-salt \n").expect("spaced salt");
1303 assert_eq!(
1304 spaced_redaction,
1305 redaction_from_direct_salt(" same-salt ")
1306 );
1307 assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1308 }
1309
1310 #[test]
1311 fn blank_redaction_salt_env_values_fail_closed() {
1312 for value in ["", "\n", " "] {
1313 with_rbac_env(
1314 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1315 || {
1316 let mut cfg = RbacConfig::default();
1317 let err = cfg.apply_env_overrides().unwrap_err();
1318 assert!(
1319 err.to_string()
1320 .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1321 );
1322 },
1323 );
1324 }
1325 }
1326
1327 #[test]
1328 fn blank_redaction_salt_file_values_fail_closed() {
1329 for value in ["", "\n", "\r\n", " \n"] {
1330 let err = redaction_from_file(value).unwrap_err();
1331 assert!(
1332 err.to_string()
1333 .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1334 );
1335 }
1336 }
1337
1338 fn redaction_from_direct_salt(salt: &str) -> String {
1339 RbacPolicy::new(&RbacConfig {
1340 redaction_salt: Some(SecretString::from(salt.to_owned())),
1341 ..RbacConfig::default()
1342 })
1343 .redact_arg("same-argument")
1344 }
1345
1346 fn redaction_from_file(
1347 content: &str,
1348 ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1349 let path = std::env::temp_dir().join(format!(
1350 "rmcp-server-kit-redaction-salt-{}.txt",
1351 std::time::SystemTime::now()
1352 .duration_since(std::time::UNIX_EPOCH)
1353 .expect("clock after epoch")
1354 .as_nanos()
1355 ));
1356 std::fs::write(&path, content).expect("write salt file");
1357 let path_string = path.to_string_lossy().to_string();
1358 let result = with_rbac_env(
1359 &[(
1360 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1361 Some(path_string.as_str()),
1362 )],
1363 || {
1364 let mut cfg = RbacConfig::default();
1365 let report = cfg.apply_env_overrides()?;
1366 let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1367 Ok((redaction, report))
1368 },
1369 );
1370 std::fs::remove_file(path).expect("remove salt file");
1371 result
1372 }
1373
1374 #[test]
1379 fn tool_limiter_burst_allows_initial_spike() {
1380 let limiter = build_tool_rate_limiter(2, Some(4));
1381 let ip: IpAddr = "10.9.9.9".parse().unwrap();
1382 for i in 0..4 {
1383 assert!(
1384 limiter.check_key(&ip).is_ok(),
1385 "burst request {i} should pass"
1386 );
1387 }
1388 assert!(
1389 limiter.check_key(&ip).is_err(),
1390 "request 5 must exceed the burst bucket"
1391 );
1392 }
1393
1394 #[test]
1396 fn tool_limiter_deny_sets_retry_after() {
1397 let limiter = build_tool_rate_limiter(1, None);
1398 let ip: IpAddr = "10.8.8.8".parse().unwrap();
1399 assert!(enforce_rate_limit(Some(&limiter), Some(ip)).is_none());
1400 let resp = enforce_rate_limit(Some(&limiter), Some(ip))
1401 .expect("second call within the window must deny");
1402 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1403 let retry_after = resp
1404 .headers()
1405 .get(axum::http::header::RETRY_AFTER)
1406 .expect("Retry-After present")
1407 .to_str()
1408 .unwrap()
1409 .parse::<u64>()
1410 .unwrap();
1411 assert!(retry_after >= 1, "delta-seconds must be >= 1");
1412 }
1413
1414 fn test_policy() -> RbacPolicy {
1415 RbacPolicy::new(&RbacConfig {
1416 enabled: true,
1417 roles: vec![
1418 RoleConfig {
1419 name: "viewer".into(),
1420 description: Some("Read-only".into()),
1421 allow: vec![
1422 "list_hosts".into(),
1423 "resource_list".into(),
1424 "resource_inspect".into(),
1425 "resource_logs".into(),
1426 "system_info".into(),
1427 ],
1428 deny: vec![],
1429 hosts: vec!["*".into()],
1430 argument_allowlists: vec![],
1431 },
1432 RoleConfig {
1433 name: "deploy".into(),
1434 description: Some("Lifecycle management".into()),
1435 allow: vec![
1436 "list_hosts".into(),
1437 "resource_list".into(),
1438 "resource_run".into(),
1439 "resource_start".into(),
1440 "resource_stop".into(),
1441 "resource_restart".into(),
1442 "resource_logs".into(),
1443 "image_pull".into(),
1444 ],
1445 deny: vec!["resource_delete".into(), "resource_exec".into()],
1446 hosts: vec!["web-*".into(), "api-*".into()],
1447 argument_allowlists: vec![],
1448 },
1449 RoleConfig {
1450 name: "ops".into(),
1451 description: Some("Full access".into()),
1452 allow: vec!["*".into()],
1453 deny: vec![],
1454 hosts: vec!["*".into()],
1455 argument_allowlists: vec![],
1456 },
1457 RoleConfig {
1458 name: "restricted-exec".into(),
1459 description: Some("Exec with argument allowlist".into()),
1460 allow: vec!["resource_exec".into()],
1461 deny: vec![],
1462 hosts: vec!["dev-*".into()],
1463 argument_allowlists: vec![ArgumentAllowlist {
1464 tool: "resource_exec".into(),
1465 argument: "cmd".into(),
1466 allowed: vec![
1467 "sh".into(),
1468 "bash".into(),
1469 "cat".into(),
1470 "ls".into(),
1471 "ps".into(),
1472 ],
1473 required: false,
1474 }],
1475 },
1476 ],
1477 redaction_salt: None,
1478 })
1479 }
1480
1481 #[test]
1484 fn glob_exact_match() {
1485 assert!(glob_match("web-prod-1", "web-prod-1"));
1486 assert!(!glob_match("web-prod-1", "web-prod-2"));
1487 }
1488
1489 #[test]
1490 fn glob_star_suffix() {
1491 assert!(glob_match("web-*", "web-prod-1"));
1492 assert!(glob_match("web-*", "web-staging"));
1493 assert!(!glob_match("web-*", "api-prod"));
1494 }
1495
1496 #[test]
1497 fn glob_star_prefix() {
1498 assert!(glob_match("*-prod", "web-prod"));
1499 assert!(glob_match("*-prod", "api-prod"));
1500 assert!(!glob_match("*-prod", "web-staging"));
1501 }
1502
1503 #[test]
1504 fn glob_star_middle() {
1505 assert!(glob_match("web-*-prod", "web-us-prod"));
1506 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1507 assert!(!glob_match("web-*-prod", "web-staging"));
1508 }
1509
1510 #[test]
1511 fn glob_star_only() {
1512 assert!(glob_match("*", "anything"));
1513 assert!(glob_match("*", ""));
1514 }
1515
1516 #[test]
1517 fn glob_multiple_stars() {
1518 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1519 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1520 }
1521
1522 #[test]
1527 fn glob_match_multibyte_utf8() {
1528 assert!(glob_match("hé*llo", "héllo"));
1529 assert!(glob_match("*ö*", "wörld"));
1530 assert!(glob_match("über*", "übermensch"));
1531 assert!(glob_match("*界", "世界"));
1532 assert!(!glob_match("hé*llo", "hello"));
1533 assert!(!glob_match("界*", "世界"));
1534 assert!(glob_match("世*界", "世界"));
1535 }
1536
1537 #[test]
1549 fn glob_prefix_and_suffix_meet_exactly() {
1550 assert!(glob_match("ab*cd", "abcd"));
1553 }
1554
1555 #[test]
1560 fn glob_middle_segment_required_with_suffix() {
1561 assert!(!glob_match("a*b*c", "axyc"));
1566 }
1567
1568 #[test]
1574 fn glob_match_middle_advances_past_matched_part() {
1575 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1580 }
1581
1582 #[test]
1587 fn glob_match_middle_uses_addition_not_multiplication() {
1588 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1592 }
1593
1594 #[test]
1603 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1604 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1612 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1613 "run-*",
1614 "cmd",
1615 vec!["ls".into()],
1616 )]);
1617 let mut config = RbacConfig::with_roles(vec![role]);
1618 config.enabled = true;
1619 let policy = RbacPolicy::new(&config);
1620 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1621 }
1622
1623 #[test]
1626 fn disabled_policy_allows_everything() {
1627 let policy = RbacPolicy::new(&RbacConfig {
1628 enabled: false,
1629 roles: vec![],
1630 redaction_salt: None,
1631 });
1632 assert_eq!(
1633 policy.check("nonexistent", "resource_delete", "any-host"),
1634 RbacDecision::Allow
1635 );
1636 }
1637
1638 #[test]
1639 fn unknown_role_denied() {
1640 let policy = test_policy();
1641 assert_eq!(
1642 policy.check("unknown", "resource_list", "web-prod-1"),
1643 RbacDecision::Deny
1644 );
1645 }
1646
1647 #[test]
1648 fn viewer_allowed_read_ops() {
1649 let policy = test_policy();
1650 assert_eq!(
1651 policy.check("viewer", "resource_list", "web-prod-1"),
1652 RbacDecision::Allow
1653 );
1654 assert_eq!(
1655 policy.check("viewer", "system_info", "db-host"),
1656 RbacDecision::Allow
1657 );
1658 }
1659
1660 #[test]
1661 fn viewer_denied_write_ops() {
1662 let policy = test_policy();
1663 assert_eq!(
1664 policy.check("viewer", "resource_run", "web-prod-1"),
1665 RbacDecision::Deny
1666 );
1667 assert_eq!(
1668 policy.check("viewer", "resource_delete", "web-prod-1"),
1669 RbacDecision::Deny
1670 );
1671 }
1672
1673 #[test]
1674 fn deploy_allowed_on_matching_hosts() {
1675 let policy = test_policy();
1676 assert_eq!(
1677 policy.check("deploy", "resource_run", "web-prod-1"),
1678 RbacDecision::Allow
1679 );
1680 assert_eq!(
1681 policy.check("deploy", "resource_start", "api-staging"),
1682 RbacDecision::Allow
1683 );
1684 }
1685
1686 #[test]
1687 fn deploy_denied_on_non_matching_host() {
1688 let policy = test_policy();
1689 assert_eq!(
1690 policy.check("deploy", "resource_run", "db-prod-1"),
1691 RbacDecision::Deny
1692 );
1693 }
1694
1695 #[test]
1696 fn deny_overrides_allow() {
1697 let policy = test_policy();
1698 assert_eq!(
1699 policy.check("deploy", "resource_delete", "web-prod-1"),
1700 RbacDecision::Deny
1701 );
1702 assert_eq!(
1703 policy.check("deploy", "resource_exec", "web-prod-1"),
1704 RbacDecision::Deny
1705 );
1706 }
1707
1708 #[test]
1709 fn ops_wildcard_allows_everything() {
1710 let policy = test_policy();
1711 assert_eq!(
1712 policy.check("ops", "resource_delete", "any-host"),
1713 RbacDecision::Allow
1714 );
1715 assert_eq!(
1716 policy.check("ops", "secret_create", "db-host"),
1717 RbacDecision::Allow
1718 );
1719 }
1720
1721 #[test]
1724 fn host_visible_respects_globs() {
1725 let policy = test_policy();
1726 assert!(policy.host_visible("deploy", "web-prod-1"));
1727 assert!(policy.host_visible("deploy", "api-staging"));
1728 assert!(!policy.host_visible("deploy", "db-prod-1"));
1729 assert!(policy.host_visible("ops", "anything"));
1730 assert!(policy.host_visible("viewer", "anything"));
1731 }
1732
1733 #[test]
1734 fn host_visible_unknown_role() {
1735 let policy = test_policy();
1736 assert!(!policy.host_visible("unknown", "web-prod-1"));
1737 }
1738
1739 #[test]
1742 fn argument_allowed_no_allowlist() {
1743 let policy = test_policy();
1744 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1746 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1747 }
1748
1749 #[test]
1750 fn argument_allowed_with_allowlist() {
1751 let policy = test_policy();
1752 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1753 assert!(policy.argument_allowed(
1754 "restricted-exec",
1755 "resource_exec",
1756 "cmd",
1757 "bash -c 'echo hi'"
1758 ));
1759 assert!(policy.argument_allowed(
1760 "restricted-exec",
1761 "resource_exec",
1762 "cmd",
1763 "cat /etc/hosts"
1764 ));
1765 assert!(policy.argument_allowed(
1766 "restricted-exec",
1767 "resource_exec",
1768 "cmd",
1769 "/usr/bin/ls -la"
1770 ));
1771 }
1772
1773 #[test]
1774 fn argument_denied_not_in_allowlist() {
1775 let policy = test_policy();
1776 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1777 assert!(!policy.argument_allowed(
1778 "restricted-exec",
1779 "resource_exec",
1780 "cmd",
1781 "python3 exploit.py"
1782 ));
1783 assert!(!policy.argument_allowed(
1784 "restricted-exec",
1785 "resource_exec",
1786 "cmd",
1787 "/usr/bin/curl evil.com"
1788 ));
1789 }
1790
1791 #[test]
1792 fn argument_denied_unknown_role() {
1793 let policy = test_policy();
1794 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1795 }
1796
1797 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1806 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1807 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1808 let mut config = RbacConfig::with_roles(vec![role]);
1809 config.enabled = true;
1810 RbacPolicy::new(&config)
1811 }
1812
1813 #[test]
1814 fn argument_allowed_matches_quoted_path_with_spaces() {
1815 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1816 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1817 }
1818
1819 #[test]
1820 fn argument_allowed_matches_basename_of_quoted_path() {
1821 let policy = shlex_policy(vec!["my tool".into()]);
1822 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1823 }
1824
1825 #[test]
1826 fn argument_allowed_fails_closed_on_unbalanced_quote() {
1827 let policy = shlex_policy(vec!["unbalanced".into()]);
1828 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1829 }
1830
1831 #[test]
1832 fn argument_allowed_fails_closed_on_empty_string() {
1833 let policy = shlex_policy(vec![String::new()]);
1834 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1835 }
1836
1837 #[test]
1838 fn argument_allowed_handles_single_quoted_executable() {
1839 let policy = shlex_policy(vec!["/bin/sh".into()]);
1840 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1841 }
1842
1843 #[test]
1844 fn argument_allowed_handles_tab_separator() {
1845 let policy = shlex_policy(vec!["ls".into()]);
1846 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1847 }
1848
1849 #[test]
1850 fn argument_allowed_plain_token_unchanged() {
1851 let policy = shlex_policy(vec!["ls".into()]);
1852 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1853 }
1854
1855 #[test]
1861 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1862 let policy = shlex_policy(vec![String::new()]);
1866 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1867 }
1868
1869 #[test]
1870 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1871 let policy = shlex_policy(vec!["'bash'".into()]);
1877 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1878 }
1879
1880 #[test]
1881 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1882 let policy = shlex_policy(vec![r"foo\bar".into()]);
1887 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1888 }
1889
1890 #[test]
1891 fn argument_allowed_windows_path_no_longer_matches() {
1892 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1897 assert!(!policy.argument_allowed(
1898 "viewer",
1899 "run",
1900 "cmd",
1901 r"C:\Windows\System32\cmd.exe /c dir"
1902 ));
1903 }
1904
1905 #[test]
1908 fn host_patterns_returns_globs() {
1909 let policy = test_policy();
1910 assert_eq!(
1911 policy.host_patterns("deploy"),
1912 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1913 );
1914 assert_eq!(
1915 policy.host_patterns("ops"),
1916 Some(vec!["*".to_owned()].as_slice())
1917 );
1918 assert!(policy.host_patterns("nonexistent").is_none());
1919 }
1920
1921 #[test]
1924 fn check_operation_allows_without_host() {
1925 let policy = test_policy();
1926 assert_eq!(
1927 policy.check_operation("deploy", "resource_run"),
1928 RbacDecision::Allow
1929 );
1930 assert_eq!(
1932 policy.check("deploy", "resource_run", "db-prod-1"),
1933 RbacDecision::Deny
1934 );
1935 }
1936
1937 #[test]
1938 fn check_operation_deny_overrides() {
1939 let policy = test_policy();
1940 assert_eq!(
1941 policy.check_operation("deploy", "resource_delete"),
1942 RbacDecision::Deny
1943 );
1944 }
1945
1946 #[test]
1947 fn check_operation_unknown_role() {
1948 let policy = test_policy();
1949 assert_eq!(
1950 policy.check_operation("unknown", "resource_list"),
1951 RbacDecision::Deny
1952 );
1953 }
1954
1955 #[test]
1956 fn check_operation_disabled() {
1957 let policy = RbacPolicy::new(&RbacConfig {
1958 enabled: false,
1959 roles: vec![],
1960 redaction_salt: None,
1961 });
1962 assert_eq!(
1963 policy.check_operation("nonexistent", "anything"),
1964 RbacDecision::Allow
1965 );
1966 }
1967
1968 #[test]
1971 fn current_role_returns_none_outside_scope() {
1972 assert!(current_role().is_none());
1973 }
1974
1975 #[test]
1976 fn current_identity_returns_none_outside_scope() {
1977 assert!(current_identity().is_none());
1978 }
1979
1980 use axum::{
1983 body::Body,
1984 http::{Method, Request, StatusCode},
1985 };
1986 use tower::ServiceExt as _;
1987
1988 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1989 serde_json::json!({
1990 "jsonrpc": "2.0",
1991 "id": 1,
1992 "method": "tools/call",
1993 "params": {
1994 "name": tool,
1995 "arguments": args
1996 }
1997 })
1998 .to_string()
1999 }
2000
2001 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
2002 axum::Router::new()
2003 .route("/mcp", axum::routing::post(|| async { "ok" }))
2004 .layer(axum::middleware::from_fn(move |req, next| {
2005 let p = Arc::clone(&policy);
2006 rbac_middleware(p, None, req, next)
2007 }))
2008 }
2009
2010 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
2011 axum::Router::new()
2012 .route("/mcp", axum::routing::post(|| async { "ok" }))
2013 .layer(axum::middleware::from_fn(
2014 move |mut req: Request<Body>, next: Next| {
2015 let p = Arc::clone(&policy);
2016 let id = identity.clone();
2017 async move {
2018 req.extensions_mut().insert(id);
2019 rbac_middleware(p, None, req, next).await
2020 }
2021 },
2022 ))
2023 }
2024
2025 #[cfg(feature = "metrics")]
2029 #[tokio::test]
2030 async fn tool_limiter_deny_increments_counter() {
2031 use axum::extract::ConnectInfo;
2032
2033 let policy = Arc::new(test_policy());
2034 let limiter = build_tool_rate_limiter(1, None);
2035 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
2036 let identity = AuthIdentity {
2037 method: crate::auth::AuthMethod::BearerToken,
2038 name: "alice".into(),
2039 role: "viewer".into(),
2040 raw_token: None,
2041 sub: None,
2042 };
2043 let app = {
2044 let metrics = Arc::clone(&metrics);
2045 axum::Router::new()
2046 .route("/mcp", axum::routing::post(|| async { "ok" }))
2047 .layer(axum::middleware::from_fn(
2048 move |mut req: Request<Body>, next: Next| {
2049 let p = Arc::clone(&policy);
2050 let l = Arc::clone(&limiter);
2051 let id = identity.clone();
2052 let m = Arc::clone(&metrics);
2053 async move {
2054 req.extensions_mut().insert(id);
2055 req.extensions_mut().insert(m);
2056 let peer: std::net::SocketAddr =
2057 "10.9.9.1:40000".parse().expect("static socket addr parses");
2058 req.extensions_mut().insert(ConnectInfo(peer));
2059 rbac_middleware(p, Some(l), req, next).await
2060 }
2061 },
2062 ))
2063 };
2064 let mk = || {
2065 Request::builder()
2066 .method(Method::POST)
2067 .uri("/mcp")
2068 .header("content-type", "application/json")
2069 .body(Body::from(tool_call_body(
2070 "resource_list",
2071 &serde_json::json!({}),
2072 )))
2073 .unwrap()
2074 };
2075 let counter = || {
2076 metrics
2077 .rate_limited_total
2078 .with_label_values(&["tool"])
2079 .get()
2080 };
2081
2082 let first = app.clone().oneshot(mk()).await.unwrap();
2083 assert_eq!(first.status(), StatusCode::OK);
2084 assert_eq!(counter(), 0, "successful call must not count");
2085
2086 let denied = app.clone().oneshot(mk()).await.unwrap();
2087 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
2088 assert_eq!(counter(), 1, "deny must increment the tool label");
2089 }
2090
2091 #[tokio::test]
2092 async fn middleware_passes_non_post() {
2093 let policy = Arc::new(test_policy());
2094 let app = rbac_router(policy);
2095 let req = Request::builder()
2097 .method(Method::GET)
2098 .uri("/mcp")
2099 .body(Body::empty())
2100 .unwrap();
2101 let resp = app.oneshot(req).await.unwrap();
2104 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
2105 }
2106
2107 #[tokio::test]
2108 async fn middleware_denies_without_identity() {
2109 let policy = Arc::new(test_policy());
2110 let app = rbac_router(policy);
2111 let body = tool_call_body("resource_list", &serde_json::json!({}));
2112 let req = Request::builder()
2113 .method(Method::POST)
2114 .uri("/mcp")
2115 .header("content-type", "application/json")
2116 .body(Body::from(body))
2117 .unwrap();
2118 let resp = app.oneshot(req).await.unwrap();
2119 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2120 }
2121
2122 #[tokio::test]
2123 async fn middleware_allows_permitted_tool() {
2124 let policy = Arc::new(test_policy());
2125 let id = AuthIdentity {
2126 method: crate::auth::AuthMethod::BearerToken,
2127 name: "alice".into(),
2128 role: "viewer".into(),
2129 raw_token: None,
2130 sub: None,
2131 };
2132 let app = rbac_router_with_identity(policy, id);
2133 let body = tool_call_body("resource_list", &serde_json::json!({}));
2134 let req = Request::builder()
2135 .method(Method::POST)
2136 .uri("/mcp")
2137 .header("content-type", "application/json")
2138 .body(Body::from(body))
2139 .unwrap();
2140 let resp = app.oneshot(req).await.unwrap();
2141 assert_eq!(resp.status(), StatusCode::OK);
2142 }
2143
2144 #[tokio::test]
2145 async fn middleware_denies_unpermitted_tool() {
2146 let policy = Arc::new(test_policy());
2147 let id = AuthIdentity {
2148 method: crate::auth::AuthMethod::BearerToken,
2149 name: "alice".into(),
2150 role: "viewer".into(),
2151 raw_token: None,
2152 sub: None,
2153 };
2154 let app = rbac_router_with_identity(policy, id);
2155 let body = tool_call_body("resource_delete", &serde_json::json!({}));
2156 let req = Request::builder()
2157 .method(Method::POST)
2158 .uri("/mcp")
2159 .header("content-type", "application/json")
2160 .body(Body::from(body))
2161 .unwrap();
2162 let resp = app.oneshot(req).await.unwrap();
2163 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2164 }
2165
2166 #[tokio::test]
2167 async fn middleware_passes_non_tool_call_post() {
2168 let policy = Arc::new(test_policy());
2169 let id = AuthIdentity {
2170 method: crate::auth::AuthMethod::BearerToken,
2171 name: "alice".into(),
2172 role: "viewer".into(),
2173 raw_token: None,
2174 sub: None,
2175 };
2176 let app = rbac_router_with_identity(policy, id);
2177 let body = serde_json::json!({
2179 "jsonrpc": "2.0",
2180 "id": 1,
2181 "method": "resources/list"
2182 })
2183 .to_string();
2184 let req = Request::builder()
2185 .method(Method::POST)
2186 .uri("/mcp")
2187 .header("content-type", "application/json")
2188 .body(Body::from(body))
2189 .unwrap();
2190 let resp = app.oneshot(req).await.unwrap();
2191 assert_eq!(resp.status(), StatusCode::OK);
2192 }
2193
2194 #[tokio::test]
2195 async fn middleware_enforces_argument_allowlist() {
2196 let policy = Arc::new(test_policy());
2197 let id = AuthIdentity {
2198 method: crate::auth::AuthMethod::BearerToken,
2199 name: "dev".into(),
2200 role: "restricted-exec".into(),
2201 raw_token: None,
2202 sub: None,
2203 };
2204 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
2206 let body = tool_call_body(
2207 "resource_exec",
2208 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
2209 );
2210 let req = Request::builder()
2211 .method(Method::POST)
2212 .uri("/mcp")
2213 .body(Body::from(body))
2214 .unwrap();
2215 let resp = app.oneshot(req).await.unwrap();
2216 assert_eq!(resp.status(), StatusCode::OK);
2217
2218 let app = rbac_router_with_identity(policy, id);
2220 let body = tool_call_body(
2221 "resource_exec",
2222 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
2223 );
2224 let req = Request::builder()
2225 .method(Method::POST)
2226 .uri("/mcp")
2227 .body(Body::from(body))
2228 .unwrap();
2229 let resp = app.oneshot(req).await.unwrap();
2230 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2231 }
2232
2233 #[tokio::test]
2234 async fn middleware_disabled_policy_passes_everything() {
2235 let policy = Arc::new(RbacPolicy::disabled());
2236 let app = rbac_router(policy);
2237 let body = tool_call_body("anything", &serde_json::json!({}));
2239 let req = Request::builder()
2240 .method(Method::POST)
2241 .uri("/mcp")
2242 .body(Body::from(body))
2243 .unwrap();
2244 let resp = app.oneshot(req).await.unwrap();
2245 assert_eq!(resp.status(), StatusCode::OK);
2246 }
2247
2248 #[tokio::test]
2249 async fn middleware_batch_all_allowed_passes() {
2250 let policy = Arc::new(test_policy());
2251 let id = AuthIdentity {
2252 method: crate::auth::AuthMethod::BearerToken,
2253 name: "alice".into(),
2254 role: "viewer".into(),
2255 raw_token: None,
2256 sub: None,
2257 };
2258 let app = rbac_router_with_identity(policy, id);
2259 let body = serde_json::json!([
2260 {
2261 "jsonrpc": "2.0",
2262 "id": 1,
2263 "method": "tools/call",
2264 "params": { "name": "resource_list", "arguments": {} }
2265 },
2266 {
2267 "jsonrpc": "2.0",
2268 "id": 2,
2269 "method": "tools/call",
2270 "params": { "name": "system_info", "arguments": {} }
2271 }
2272 ])
2273 .to_string();
2274 let req = Request::builder()
2275 .method(Method::POST)
2276 .uri("/mcp")
2277 .header("content-type", "application/json")
2278 .body(Body::from(body))
2279 .unwrap();
2280 let resp = app.oneshot(req).await.unwrap();
2281 assert_eq!(resp.status(), StatusCode::OK);
2282 }
2283
2284 #[tokio::test]
2285 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
2286 let policy = Arc::new(test_policy());
2287 let id = AuthIdentity {
2288 method: crate::auth::AuthMethod::BearerToken,
2289 name: "alice".into(),
2290 role: "viewer".into(),
2291 raw_token: None,
2292 sub: None,
2293 };
2294 let app = rbac_router_with_identity(policy, id);
2295 let body = serde_json::json!([
2296 {
2297 "jsonrpc": "2.0",
2298 "id": 1,
2299 "method": "tools/call",
2300 "params": { "name": "resource_list", "arguments": {} }
2301 },
2302 {
2303 "jsonrpc": "2.0",
2304 "id": 2,
2305 "method": "tools/call",
2306 "params": { "name": "resource_delete", "arguments": {} }
2307 }
2308 ])
2309 .to_string();
2310 let req = Request::builder()
2311 .method(Method::POST)
2312 .uri("/mcp")
2313 .header("content-type", "application/json")
2314 .body(Body::from(body))
2315 .unwrap();
2316 let resp = app.oneshot(req).await.unwrap();
2317 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2318 }
2319
2320 #[tokio::test]
2321 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
2322 let policy = Arc::new(test_policy());
2323 let id = AuthIdentity {
2324 method: crate::auth::AuthMethod::BearerToken,
2325 name: "dev".into(),
2326 role: "restricted-exec".into(),
2327 raw_token: None,
2328 sub: None,
2329 };
2330 let app = rbac_router_with_identity(policy, id);
2331 let body = serde_json::json!([
2332 {
2333 "jsonrpc": "2.0",
2334 "id": 1,
2335 "method": "tools/call",
2336 "params": {
2337 "name": "resource_exec",
2338 "arguments": { "cmd": "ls -la", "host": "dev-1" }
2339 }
2340 },
2341 {
2342 "jsonrpc": "2.0",
2343 "id": 2,
2344 "method": "tools/call",
2345 "params": {
2346 "name": "resource_exec",
2347 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
2348 }
2349 }
2350 ])
2351 .to_string();
2352 let req = Request::builder()
2353 .method(Method::POST)
2354 .uri("/mcp")
2355 .header("content-type", "application/json")
2356 .body(Body::from(body))
2357 .unwrap();
2358 let resp = app.oneshot(req).await.unwrap();
2359 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2360 }
2361
2362 #[test]
2365 fn redact_with_salt_is_deterministic_per_salt() {
2366 let salt = b"unit-test-salt";
2367 let a = redact_with_salt(salt, "rm -rf /");
2368 let b = redact_with_salt(salt, "rm -rf /");
2369 assert_eq!(a, b, "same input + salt must yield identical hash");
2370 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
2371 assert!(
2372 a.chars().all(|c| c.is_ascii_hexdigit()),
2373 "redacted hash must be lowercase hex: {a}"
2374 );
2375 }
2376
2377 #[test]
2378 fn redact_with_salt_differs_across_salts() {
2379 let v = "the-same-value";
2380 let h1 = redact_with_salt(b"salt-one", v);
2381 let h2 = redact_with_salt(b"salt-two", v);
2382 assert_ne!(
2383 h1, h2,
2384 "different salts must produce different hashes for the same value"
2385 );
2386 }
2387
2388 #[test]
2389 fn redact_with_salt_distinguishes_values() {
2390 let salt = b"k";
2391 let h1 = redact_with_salt(salt, "alpha");
2392 let h2 = redact_with_salt(salt, "beta");
2393 assert_ne!(h1, h2, "different values must produce different hashes");
2395 }
2396
2397 #[test]
2398 fn policy_with_configured_salt_redacts_consistently() {
2399 let cfg = RbacConfig {
2400 enabled: true,
2401 roles: vec![],
2402 redaction_salt: Some(SecretString::from("my-stable-salt")),
2403 };
2404 let p1 = RbacPolicy::new(&cfg);
2405 let p2 = RbacPolicy::new(&cfg);
2406 assert_eq!(
2407 p1.redact_arg("payload"),
2408 p2.redact_arg("payload"),
2409 "policies built from the same configured salt must agree"
2410 );
2411 }
2412
2413 #[test]
2414 fn policy_without_configured_salt_uses_process_salt() {
2415 let cfg = RbacConfig {
2416 enabled: true,
2417 roles: vec![],
2418 redaction_salt: None,
2419 };
2420 let p1 = RbacPolicy::new(&cfg);
2421 let p2 = RbacPolicy::new(&cfg);
2422 assert_eq!(
2424 p1.redact_arg("payload"),
2425 p2.redact_arg("payload"),
2426 "process-wide salt must be consistent within one process"
2427 );
2428 }
2429
2430 #[test]
2431 fn redact_arg_is_fast_enough() {
2432 let salt = b"perf-sanity-salt-32-bytes-padded";
2436 let value = "x".repeat(256);
2437 let start = std::time::Instant::now();
2438 let _ = redact_with_salt(salt, &value);
2439 let elapsed = start.elapsed();
2440 assert!(
2441 elapsed < Duration::from_millis(5),
2442 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
2443 );
2444 }
2445
2446 #[tokio::test]
2458 async fn deny_path_uses_explicit_identity_not_task_local() {
2459 let policy = Arc::new(test_policy());
2460 let id = AuthIdentity {
2461 method: crate::auth::AuthMethod::BearerToken,
2462 name: "alice-the-auditor".into(),
2463 role: "viewer".into(),
2464 raw_token: None,
2465 sub: None,
2466 };
2467 let app = rbac_router_with_identity(policy, id);
2468 let body = tool_call_body("resource_delete", &serde_json::json!({}));
2470 let req = Request::builder()
2471 .method(Method::POST)
2472 .uri("/mcp")
2473 .header("content-type", "application/json")
2474 .body(Body::from(body))
2475 .unwrap();
2476 let resp = app.oneshot(req).await.unwrap();
2477 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2478 }
2479
2480 fn restricted_exec_identity() -> AuthIdentity {
2483 AuthIdentity {
2484 method: crate::auth::AuthMethod::BearerToken,
2485 name: "carol".into(),
2486 role: "restricted-exec".into(),
2487 raw_token: None,
2488 sub: None,
2489 }
2490 }
2491
2492 #[test]
2493 fn has_argument_allowlist_matches_configured_tool_argument() {
2494 let policy = test_policy();
2495 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2496 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2497 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2498 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2499 }
2500
2501 #[tokio::test]
2502 async fn array_arg_with_matching_allowlist_is_denied() {
2503 let policy = Arc::new(test_policy());
2504 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2505 let body = tool_call_body(
2506 "resource_exec",
2507 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2508 );
2509 let req = Request::builder()
2510 .method(Method::POST)
2511 .uri("/mcp")
2512 .header("content-type", "application/json")
2513 .body(Body::from(body))
2514 .unwrap();
2515 let resp = app.oneshot(req).await.unwrap();
2516 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2517 }
2518
2519 #[tokio::test]
2520 async fn object_arg_with_matching_allowlist_is_denied() {
2521 let policy = Arc::new(test_policy());
2522 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2523 let body = tool_call_body(
2524 "resource_exec",
2525 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2526 );
2527 let req = Request::builder()
2528 .method(Method::POST)
2529 .uri("/mcp")
2530 .header("content-type", "application/json")
2531 .body(Body::from(body))
2532 .unwrap();
2533 let resp = app.oneshot(req).await.unwrap();
2534 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2535 }
2536
2537 #[tokio::test]
2538 async fn number_arg_with_matching_allowlist_is_denied() {
2539 let policy = Arc::new(test_policy());
2540 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2541 let body = tool_call_body(
2542 "resource_exec",
2543 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2544 );
2545 let req = Request::builder()
2546 .method(Method::POST)
2547 .uri("/mcp")
2548 .header("content-type", "application/json")
2549 .body(Body::from(body))
2550 .unwrap();
2551 let resp = app.oneshot(req).await.unwrap();
2552 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2553 }
2554
2555 #[tokio::test]
2556 async fn bool_arg_with_matching_allowlist_is_denied() {
2557 let policy = Arc::new(test_policy());
2558 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2559 let body = tool_call_body(
2560 "resource_exec",
2561 &serde_json::json!({ "host": "dev-1", "cmd": true }),
2562 );
2563 let req = Request::builder()
2564 .method(Method::POST)
2565 .uri("/mcp")
2566 .header("content-type", "application/json")
2567 .body(Body::from(body))
2568 .unwrap();
2569 let resp = app.oneshot(req).await.unwrap();
2570 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2571 }
2572
2573 #[tokio::test]
2574 async fn null_arg_with_matching_allowlist_is_denied() {
2575 let policy = Arc::new(test_policy());
2576 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2577 let body = tool_call_body(
2578 "resource_exec",
2579 &serde_json::json!({ "host": "dev-1", "cmd": null }),
2580 );
2581 let req = Request::builder()
2582 .method(Method::POST)
2583 .uri("/mcp")
2584 .header("content-type", "application/json")
2585 .body(Body::from(body))
2586 .unwrap();
2587 let resp = app.oneshot(req).await.unwrap();
2588 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2589 }
2590
2591 #[tokio::test]
2592 async fn non_string_arg_without_allowlist_is_passthrough() {
2593 let policy = Arc::new(test_policy());
2597 let id = AuthIdentity {
2598 method: crate::auth::AuthMethod::BearerToken,
2599 name: "olivia".into(),
2600 role: "ops".into(),
2601 raw_token: None,
2602 sub: None,
2603 };
2604 let app = rbac_router_with_identity(policy, id);
2605 let body = tool_call_body(
2606 "resource_exec",
2607 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2608 );
2609 let req = Request::builder()
2610 .method(Method::POST)
2611 .uri("/mcp")
2612 .header("content-type", "application/json")
2613 .body(Body::from(body))
2614 .unwrap();
2615 let resp = app.oneshot(req).await.unwrap();
2616 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2617 }
2618
2619 #[tokio::test]
2620 async fn string_arg_in_allowlist_still_passes() {
2621 let policy = Arc::new(test_policy());
2622 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2623 let body = tool_call_body(
2624 "resource_exec",
2625 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2626 );
2627 let req = Request::builder()
2628 .method(Method::POST)
2629 .uri("/mcp")
2630 .header("content-type", "application/json")
2631 .body(Body::from(body))
2632 .unwrap();
2633 let resp = app.oneshot(req).await.unwrap();
2634 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2635 }
2636
2637 async fn exec_status(args: &serde_json::Value) -> StatusCode {
2646 let policy = Arc::new(test_policy());
2647 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2648 let body = tool_call_body("resource_exec", args);
2649 let req = Request::builder()
2650 .method(Method::POST)
2651 .uri("/mcp")
2652 .header("content-type", "application/json")
2653 .body(Body::from(body))
2654 .unwrap();
2655 app.oneshot(req).await.unwrap().status()
2656 }
2657
2658 #[tokio::test]
2659 async fn non_string_host_is_denied_for_every_json_type() {
2660 for host in [
2661 serde_json::json!(["prod-1"]),
2662 serde_json::json!({ "name": "prod-1" }),
2663 serde_json::json!(42),
2664 serde_json::json!(true),
2665 serde_json::json!(null),
2666 ] {
2667 let args = serde_json::json!({ "host": host, "cmd": "sh" });
2668 assert_eq!(
2669 exec_status(&args).await,
2670 StatusCode::FORBIDDEN,
2671 "non-string host must not bypass host globs: {host:?}"
2672 );
2673 }
2674 }
2675
2676 #[tokio::test]
2677 async fn string_host_outside_globs_still_denied() {
2678 let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
2679 assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
2680 }
2681
2682 #[tokio::test]
2683 async fn string_host_inside_globs_still_allowed() {
2684 let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
2685 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
2686 }
2687
2688 #[tokio::test]
2692 async fn absent_host_still_routes_to_check_operation() {
2693 let args = serde_json::json!({ "cmd": "sh" });
2694 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
2695 }
2696
2697 fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
2706 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2707 .with_argument_allowlists(vec![
2708 ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
2709 ]);
2710 let mut config = RbacConfig::with_roles(vec![role]);
2711 config.enabled = true;
2712 RbacPolicy::new(&config)
2713 }
2714
2715 fn viewer_identity() -> AuthIdentity {
2716 AuthIdentity {
2717 method: crate::auth::AuthMethod::BearerToken,
2718 name: "viewer-1".into(),
2719 role: "viewer".into(),
2720 raw_token: None,
2721 sub: None,
2722 }
2723 }
2724
2725 async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
2726 let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
2727 let body = serde_json::json!({
2728 "jsonrpc": "2.0",
2729 "id": 1,
2730 "method": "tools/call",
2731 "params": params
2732 })
2733 .to_string();
2734 let req = Request::builder()
2735 .method(Method::POST)
2736 .uri("/mcp")
2737 .header("content-type", "application/json")
2738 .body(Body::from(body))
2739 .unwrap();
2740 app.oneshot(req).await.unwrap().status()
2741 }
2742
2743 #[tokio::test]
2744 async fn required_false_still_allows_omitting_the_argument() {
2745 let params = serde_json::json!({ "name": "run", "arguments": {} });
2746 assert_ne!(
2747 run_status(required_policy(vec!["ls".into()], false), ¶ms).await,
2748 StatusCode::FORBIDDEN,
2749 "default behaviour must be unchanged"
2750 );
2751 }
2752
2753 #[tokio::test]
2754 async fn required_true_denies_omitted_argument() {
2755 let params = serde_json::json!({ "name": "run", "arguments": {} });
2756 assert_eq!(
2757 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
2758 StatusCode::FORBIDDEN
2759 );
2760 }
2761
2762 #[tokio::test]
2763 async fn required_true_allows_permitted_value() {
2764 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
2765 assert_ne!(
2766 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
2767 StatusCode::FORBIDDEN
2768 );
2769 }
2770
2771 #[tokio::test]
2772 async fn required_true_still_denies_disallowed_value() {
2773 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
2774 assert_eq!(
2775 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
2776 StatusCode::FORBIDDEN
2777 );
2778 }
2779
2780 #[tokio::test]
2781 async fn required_true_denies_non_string_value() {
2782 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
2783 assert_eq!(
2784 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
2785 StatusCode::FORBIDDEN
2786 );
2787 }
2788
2789 #[tokio::test]
2790 async fn required_true_denies_absent_or_non_object_arguments() {
2791 for params in [
2792 serde_json::json!({ "name": "run" }),
2793 serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
2794 serde_json::json!({ "name": "run", "arguments": null }),
2795 ] {
2796 assert_eq!(
2797 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
2798 StatusCode::FORBIDDEN,
2799 "omitting the arguments object must not skip `required`: {params:?}"
2800 );
2801 }
2802 }
2803
2804 #[tokio::test]
2807 async fn required_true_with_empty_allowed_accepts_any_string() {
2808 let params =
2809 serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
2810 assert_ne!(
2811 run_status(required_policy(vec![], true), ¶ms).await,
2812 StatusCode::FORBIDDEN
2813 );
2814 }
2815
2816 #[tokio::test]
2817 async fn required_true_with_empty_allowed_denies_omitted_argument() {
2818 let params = serde_json::json!({ "name": "run", "arguments": {} });
2819 assert_eq!(
2820 run_status(required_policy(vec![], true), ¶ms).await,
2821 StatusCode::FORBIDDEN
2822 );
2823 }
2824
2825 #[tokio::test]
2826 async fn required_true_with_empty_allowed_denies_non_string() {
2827 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
2828 assert_eq!(
2829 run_status(required_policy(vec![], true), ¶ms).await,
2830 StatusCode::FORBIDDEN
2831 );
2832 }
2833
2834 #[tokio::test]
2835 async fn required_honours_globbed_tool_patterns() {
2836 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2837 .with_argument_allowlists(vec![
2838 ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
2839 ]);
2840 let mut config = RbacConfig::with_roles(vec![role]);
2841 config.enabled = true;
2842 let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
2843 assert_eq!(
2844 run_status(RbacPolicy::new(&config), ¶ms).await,
2845 StatusCode::FORBIDDEN,
2846 "a globbed tool pattern must enforce presence, not just value"
2847 );
2848 }
2849
2850 #[test]
2851 fn required_defaults_to_false_when_absent_from_toml() {
2852 let cfg: RbacConfig = toml::from_str(
2853 r#"
2854 enabled = true
2855 [[roles]]
2856 name = "viewer"
2857 allow = ["run"]
2858 [[roles.argument_allowlists]]
2859 tool = "run"
2860 argument = "cmd"
2861 allowed = ["ls"]
2862 "#,
2863 )
2864 .expect("config without `required` must still deserialize");
2865 assert!(
2866 !cfg.roles[0].argument_allowlists[0].required,
2867 "omitted `required` must default to false so existing configs are unchanged"
2868 );
2869 }
2870}