1use std::{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::{
26 auth::AuthIdentity,
27 bounded_limiter::{BoundedKeyedLimiter, BoundedLimiterDeny, KeyEvictionPolicy},
28 error::RmcpServerKitError,
29};
30
31pub(crate) type ToolRateLimiter = BoundedKeyedLimiter<crate::transport::RateLimitKey>;
34
35const DEFAULT_TOOL_RATE: NonZeroU32 = NonZeroU32::new(120).unwrap();
38
39const DEFAULT_TOOL_MAX_TRACKED_KEYS: usize = 10_000;
42
43const DEFAULT_TOOL_IDLE_EVICTION: Duration = Duration::from_mins(15);
45
46#[must_use]
52pub(crate) fn build_tool_rate_limiter_with_policy(
53 max_per_minute: u32,
54 burst: Option<u32>,
55 key_eviction_policy: KeyEvictionPolicy,
56) -> Arc<ToolRateLimiter> {
57 build_tool_rate_limiter_with_bounds(
58 max_per_minute,
59 burst,
60 DEFAULT_TOOL_MAX_TRACKED_KEYS,
61 DEFAULT_TOOL_IDLE_EVICTION,
62 key_eviction_policy,
63 )
64}
65
66#[must_use]
72pub(crate) fn build_tool_rate_limiter_with_bounds(
73 max_per_minute: u32,
74 burst: Option<u32>,
75 max_tracked_keys: usize,
76 idle_eviction: Duration,
77 key_eviction_policy: KeyEvictionPolicy,
78) -> Arc<ToolRateLimiter> {
79 let mut quota =
80 governor::Quota::per_minute(NonZeroU32::new(max_per_minute).unwrap_or(DEFAULT_TOOL_RATE));
81 if let Some(b) = burst.and_then(NonZeroU32::new) {
82 quota = quota.allow_burst(b);
83 }
84 Arc::new(BoundedKeyedLimiter::new_with_policy(
85 quota,
86 std::num::NonZeroUsize::new(max_tracked_keys).unwrap_or(std::num::NonZeroUsize::MIN),
87 idle_eviction,
88 key_eviction_policy,
89 ))
90}
91
92tokio::task_local! {
99 static CURRENT_ROLE: String;
100 static CURRENT_IDENTITY: String;
101 static CURRENT_TOKEN: SecretString;
102 static CURRENT_SUB: String;
103}
104
105#[must_use]
108pub fn current_role() -> Option<String> {
109 CURRENT_ROLE.try_with(Clone::clone).ok()
110}
111
112#[must_use]
115pub fn current_identity() -> Option<String> {
116 CURRENT_IDENTITY.try_with(Clone::clone).ok()
117}
118
119#[must_use]
133pub fn current_token() -> Option<SecretString> {
134 CURRENT_TOKEN
135 .try_with(|t| {
136 if t.expose_secret().is_empty() {
137 None
138 } else {
139 Some(t.clone())
140 }
141 })
142 .ok()
143 .flatten()
144}
145
146#[must_use]
150pub fn current_sub() -> Option<String> {
151 CURRENT_SUB
152 .try_with(Clone::clone)
153 .ok()
154 .filter(|s| !s.is_empty())
155}
156
157pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
164 CURRENT_TOKEN.scope(token, f).await
165}
166
167pub async fn with_rbac_scope<F: Future>(
174 role: String,
175 identity: String,
176 token: SecretString,
177 sub: String,
178 f: F,
179) -> F::Output {
180 CURRENT_ROLE
181 .scope(
182 role,
183 CURRENT_IDENTITY.scope(
184 identity,
185 CURRENT_TOKEN.scope(token, CURRENT_SUB.scope(sub, f)),
186 ),
187 )
188 .await
189}
190
191#[derive(Debug, Clone, Deserialize)]
193#[serde(deny_unknown_fields)]
194#[non_exhaustive]
195pub struct RoleConfig {
196 pub name: String,
198 #[serde(default)]
200 pub description: Option<String>,
201 #[serde(default)]
203 pub allow: Vec<String>,
204 #[serde(default)]
206 pub deny: Vec<String>,
207 #[serde(default = "default_hosts")]
209 pub hosts: Vec<String>,
210 #[serde(default)]
214 pub argument_allowlists: Vec<ArgumentAllowlist>,
215}
216
217impl RoleConfig {
218 #[must_use]
220 pub fn new(name: impl Into<String>, allow: Vec<String>, hosts: Vec<String>) -> Self {
221 Self {
222 name: name.into(),
223 description: None,
224 allow,
225 deny: vec![],
226 hosts,
227 argument_allowlists: vec![],
228 }
229 }
230
231 #[must_use]
233 pub fn with_argument_allowlists(mut self, allowlists: Vec<ArgumentAllowlist>) -> Self {
234 self.argument_allowlists = allowlists;
235 self
236 }
237}
238
239#[derive(Debug, Clone, Deserialize)]
273#[serde(deny_unknown_fields)]
274#[non_exhaustive]
275pub struct ArgumentAllowlist {
276 pub tool: String,
278 pub argument: String,
280 #[serde(default)]
282 pub allowed: Vec<String>,
283 #[serde(default)]
296 pub required: bool,
297 #[serde(default)]
311 pub deny_unknown_arguments: bool,
312}
313
314impl ArgumentAllowlist {
315 #[must_use]
321 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
322 Self {
323 tool: tool.into(),
324 argument: argument.into(),
325 allowed,
326 required: false,
327 deny_unknown_arguments: false,
328 }
329 }
330
331 #[must_use]
336 pub fn new_required(
337 tool: impl Into<String>,
338 argument: impl Into<String>,
339 allowed: Vec<String>,
340 ) -> Self {
341 Self::new(tool, argument, allowed).with_required(true)
342 }
343
344 #[must_use]
346 pub const fn with_required(mut self, required: bool) -> Self {
347 self.required = required;
348 self
349 }
350
351 #[must_use]
356 pub const fn with_deny_unknown_arguments(mut self, deny: bool) -> Self {
357 self.deny_unknown_arguments = deny;
358 self
359 }
360}
361
362fn default_hosts() -> Vec<String> {
363 vec!["*".into()]
364}
365
366#[derive(Debug, Clone, Default, Deserialize)]
368#[serde(deny_unknown_fields)]
369#[non_exhaustive]
370pub struct RbacConfig {
371 #[serde(default)]
373 pub enabled: bool,
374 #[serde(default)]
376 pub roles: Vec<RoleConfig>,
377 #[serde(default)]
386 pub redaction_salt: Option<SecretString>,
387}
388
389impl RbacConfig {
390 #[must_use]
392 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
393 Self {
394 enabled: true,
395 roles,
396 redaction_salt: None,
397 }
398 }
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403#[non_exhaustive]
404pub enum RbacDecision {
405 Allow,
407 Deny,
409}
410
411#[derive(Debug, Clone, serde::Serialize)]
413#[non_exhaustive]
414pub struct RbacRoleSummary {
415 pub name: String,
417 pub allow: usize,
419 pub deny: usize,
421 pub hosts: usize,
423 pub argument_allowlists: usize,
425}
426
427#[derive(Debug, Clone, serde::Serialize)]
429#[non_exhaustive]
430pub struct RbacPolicySummary {
431 pub enabled: bool,
433 pub roles: Vec<RbacRoleSummary>,
435}
436
437#[derive(Debug, Clone)]
443#[non_exhaustive]
444pub struct RbacPolicy {
445 roles: Vec<RoleConfig>,
446 enabled: bool,
447 redaction_salt: Arc<SecretString>,
450}
451
452impl RbacPolicy {
453 #[must_use]
456 pub fn new(config: &RbacConfig) -> Self {
457 warn_on_optional_value_allowlists(&config.roles);
458 let salt = config
459 .redaction_salt
460 .clone()
461 .unwrap_or_else(|| process_redaction_salt().clone());
462 Self {
463 roles: config.roles.clone(),
464 enabled: config.enabled,
465 redaction_salt: Arc::new(salt),
466 }
467 }
468
469 #[must_use]
471 pub fn disabled() -> Self {
472 Self {
473 roles: Vec::new(),
474 enabled: false,
475 redaction_salt: Arc::new(process_redaction_salt().clone()),
476 }
477 }
478
479 #[must_use]
481 pub fn is_enabled(&self) -> bool {
482 self.enabled
483 }
484
485 #[must_use]
490 pub fn summary(&self) -> RbacPolicySummary {
491 let roles = self
492 .roles
493 .iter()
494 .map(|r| RbacRoleSummary {
495 name: r.name.clone(),
496 allow: r.allow.len(),
497 deny: r.deny.len(),
498 hosts: r.hosts.len(),
499 argument_allowlists: r.argument_allowlists.len(),
500 })
501 .collect();
502 RbacPolicySummary {
503 enabled: self.enabled,
504 roles,
505 }
506 }
507
508 #[must_use]
513 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
514 if !self.enabled {
515 return RbacDecision::Allow;
516 }
517 let Some(role_cfg) = self.find_role(role) else {
518 return RbacDecision::Deny;
519 };
520 if role_cfg.deny.iter().any(|d| d == operation) {
521 return RbacDecision::Deny;
522 }
523 if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
524 return RbacDecision::Allow;
525 }
526 RbacDecision::Deny
527 }
528
529 #[must_use]
537 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
538 if !self.enabled {
539 return RbacDecision::Allow;
540 }
541 let Some(role_cfg) = self.find_role(role) else {
542 return RbacDecision::Deny;
543 };
544 if role_cfg.deny.iter().any(|d| d == operation) {
545 return RbacDecision::Deny;
546 }
547 if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
548 return RbacDecision::Deny;
549 }
550 if !Self::host_matches(&role_cfg.hosts, host) {
551 return RbacDecision::Deny;
552 }
553 RbacDecision::Allow
554 }
555
556 #[must_use]
560 pub fn host_visible(&self, role: &str, host: &str) -> bool {
561 if !self.enabled {
562 return true;
563 }
564 let Some(role_cfg) = self.find_role(role) else {
565 return false;
566 };
567 Self::host_matches(&role_cfg.hosts, host)
568 }
569
570 #[must_use]
572 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
573 self.find_role(role).map(|r| r.hosts.as_slice())
574 }
575
576 #[must_use]
615 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
616 if !self.enabled {
617 return true;
618 }
619 let Some(role_cfg) = self.find_role(role) else {
620 return false;
621 };
622 for al in &role_cfg.argument_allowlists {
623 if al.tool != tool && !glob_match(&al.tool, tool) {
624 continue;
625 }
626 if al.argument != argument {
627 continue;
628 }
629 if al.allowed.is_empty() {
630 continue;
631 }
632 let Some(tokens) = shlex::split(value) else {
637 return false;
638 };
639 let Some(first_token) = tokens.first() else {
640 return false;
641 };
642 if first_token.is_empty() {
646 return false;
647 }
648 let basename = first_token
652 .rsplit('/')
653 .next()
654 .unwrap_or(first_token.as_str());
655 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
656 return false;
657 }
658 }
659 true
660 }
661
662 #[must_use]
672 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
673 if !self.enabled {
674 return false;
675 }
676 let Some(role_cfg) = self.find_role(role) else {
677 return false;
678 };
679 role_cfg.argument_allowlists.iter().any(|al| {
680 (al.tool == tool || glob_match(&al.tool, tool))
681 && al.argument == argument
682 && !al.allowed.is_empty()
683 })
684 }
685
686 fn strict_argument_names(&self, role: &str, tool: &str) -> Option<Vec<&str>> {
696 if !self.enabled {
697 return None;
698 }
699 let role_cfg = self.find_role(role)?;
700 let matching = || {
701 role_cfg
702 .argument_allowlists
703 .iter()
704 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
705 };
706 if !matching().any(|al| al.deny_unknown_arguments) {
707 return None;
708 }
709 Some(matching().map(|al| al.argument.as_str()).collect())
710 }
711
712 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
714 self.roles.iter().find(|r| r.name == name)
715 }
716
717 fn missing_required_argument(
728 &self,
729 role: &str,
730 tool: &str,
731 args: Option<&serde_json::Map<String, serde_json::Value>>,
732 ) -> Option<&str> {
733 if !self.enabled {
734 return None;
735 }
736 let role_cfg = self.find_role(role)?;
737 role_cfg
738 .argument_allowlists
739 .iter()
740 .filter(|al| al.required)
741 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
745 .find(|al| {
746 !args.is_some_and(|a| {
747 a.get(&al.argument)
748 .is_some_and(serde_json::Value::is_string)
749 })
750 })
751 .map(|al| al.argument.as_str())
752 }
753
754 fn host_matches(patterns: &[String], host: &str) -> bool {
774 let host_lower = patterns
778 .iter()
779 .any(|p| p.contains('*'))
780 .then(|| host.to_ascii_lowercase());
781 patterns.iter().any(|p| {
782 if p.contains('*') {
783 host_lower
784 .as_deref()
785 .is_some_and(|h| glob_match(&p.to_ascii_lowercase(), h))
786 } else {
787 p.eq_ignore_ascii_case(host)
788 }
789 })
790 }
791
792 #[must_use]
801 pub fn redact_arg(&self, value: &str) -> String {
802 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
803 }
804}
805
806fn warn_on_optional_value_allowlists(roles: &[RoleConfig]) {
807 for role in roles {
808 for allowlist in &role.argument_allowlists {
809 if !allowlist.allowed.is_empty() && !allowlist.required {
810 tracing::warn!(
811 role = %role.name,
812 tool = %allowlist.tool,
813 argument = %allowlist.argument,
814 "optional argument allowlist may fail open"
815 );
816 }
817 }
818 }
819}
820
821fn process_redaction_salt() -> &'static SecretString {
824 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
825 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
826 PROCESS_SALT.get_or_init(|| {
827 let mut bytes = [0u8; 32];
828 rand::fill(&mut bytes);
829 SecretString::from(STANDARD_NO_PAD.encode(bytes))
832 })
833}
834
835fn redact_with_salt(salt: &[u8], value: &str) -> String {
840 use std::fmt::Write as _;
841
842 use sha2::Digest as _;
843
844 type HmacSha256 = Hmac<Sha256>;
845 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
851 m
852 } else {
853 let digest = Sha256::digest(salt);
854 #[allow(
855 clippy::expect_used,
856 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
857 )]
858 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
859 };
860 mac.update(value.as_bytes());
861 let bytes = mac.finalize().into_bytes();
862 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
864 let mut out = String::with_capacity(8);
865 for b in prefix {
866 let _ = write!(out, "{b:02x}");
867 }
868 out
869}
870
871#[allow(
892 clippy::too_many_lines,
893 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
894)]
895pub(crate) async fn rbac_middleware(
899 policy: Arc<RbacPolicy>,
900 tool_limiter: Option<Arc<ToolRateLimiter>>,
901 req: Request<Body>,
902 next: Next,
903) -> Response {
904 if req.method() != Method::POST {
906 return next.run(req).await;
907 }
908
909 let peer_key = tool_limiter
915 .is_some()
916 .then(|| crate::transport::limiter_client_key(req.extensions()));
917
918 let identity = req.extensions().get::<AuthIdentity>();
920 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
921 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
922 let raw_token: SecretString = identity
925 .and_then(|id| id.raw_token.clone())
926 .unwrap_or_else(|| SecretString::from(String::new()));
927 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
928
929 if policy.is_enabled() && identity.is_none() {
931 return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
932 }
933
934 let (parts, body) = req.into_parts();
936 let bytes = match body.collect().await {
937 Ok(collected) => collected.to_bytes(),
938 Err(e) => {
939 tracing::error!(error = %e, "failed to read request body");
940 return (
941 StatusCode::INTERNAL_SERVER_ERROR,
942 "failed to read request body",
943 )
944 .into_response();
945 }
946 };
947
948 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
950 let tool_calls = extract_tool_calls(&json);
951 if !tool_calls.is_empty() {
952 for params in tool_calls {
953 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_key.as_ref()) {
954 #[cfg(feature = "metrics")]
955 crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
956 return resp;
957 }
958 if policy.is_enabled()
959 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
960 {
961 return resp;
962 }
963 }
964 }
965 }
966 let req = Request::from_parts(parts, Body::from(bytes));
970
971 if role.is_empty() {
973 next.run(req).await
974 } else {
975 CURRENT_ROLE
976 .scope(
977 role,
978 CURRENT_IDENTITY.scope(
979 identity_name,
980 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
981 ),
982 )
983 .await
984 }
985}
986
987fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
993 match value {
994 serde_json::Value::Object(map) => map
995 .get("method")
996 .and_then(serde_json::Value::as_str)
997 .filter(|method| *method == "tools/call")
998 .and_then(|_| map.get("params"))
999 .into_iter()
1000 .collect(),
1001 serde_json::Value::Array(items) => items
1002 .iter()
1003 .filter_map(|item| match item {
1004 serde_json::Value::Object(map) => map
1005 .get("method")
1006 .and_then(serde_json::Value::as_str)
1007 .filter(|method| *method == "tools/call")
1008 .and_then(|_| map.get("params")),
1009 serde_json::Value::Null
1010 | serde_json::Value::Bool(_)
1011 | serde_json::Value::Number(_)
1012 | serde_json::Value::String(_)
1013 | serde_json::Value::Array(_) => None,
1014 })
1015 .collect(),
1016 serde_json::Value::Null
1017 | serde_json::Value::Bool(_)
1018 | serde_json::Value::Number(_)
1019 | serde_json::Value::String(_) => Vec::new(),
1020 }
1021}
1022
1023fn enforce_rate_limit(
1026 tool_limiter: Option<&ToolRateLimiter>,
1027 peer_key: Option<&crate::transport::RateLimitKey>,
1028) -> Option<Response> {
1029 let limiter = tool_limiter?;
1030 let key = peer_key?;
1031 match limiter.check_key_detailed(key) {
1032 Ok(()) => None,
1033 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1034 tracing::warn!(rate_limit_key = %key, "tool invocation rate limited");
1035 Some(
1036 RmcpServerKitError::RateLimitedFor {
1037 message: "too many tool invocations".into(),
1038 retry_after: wait,
1039 }
1040 .into_response(),
1041 )
1042 }
1043 Err(BoundedLimiterDeny::CapacityFull) => {
1044 tracing::warn!(
1045 rate_limit_key = %key,
1046 "tool invocation limiter rejected unseen key because tracked-key capacity is full"
1047 );
1048 Some(
1049 (
1050 StatusCode::SERVICE_UNAVAILABLE,
1051 "rate limiter capacity exhausted",
1052 )
1053 .into_response(),
1054 )
1055 }
1056 }
1057}
1058
1059fn enforce_tool_policy(
1068 policy: &RbacPolicy,
1069 identity_name: &str,
1070 role: &str,
1071 params: &serde_json::Value,
1072) -> Option<Response> {
1073 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
1074 let host_value = params.get("arguments").and_then(|a| a.get("host"));
1075
1076 if let Some(value) = host_value
1084 && !value.is_string()
1085 {
1086 tracing::warn!(
1087 user = %identity_name,
1088 role = %role,
1089 tool = tool_name,
1090 value_type = json_value_type(value),
1091 "non-string host argument rejected"
1092 );
1093 return Some(
1094 RmcpServerKitError::Rbac(format!(
1095 "argument 'host' must be a string for tool '{tool_name}'"
1096 ))
1097 .into_response(),
1098 );
1099 }
1100 let host = host_value.and_then(|h| h.as_str());
1103
1104 let decision = if let Some(host) = host {
1105 policy.check(role, tool_name, host)
1106 } else {
1107 policy.check_operation(role, tool_name)
1108 };
1109 if decision == RbacDecision::Deny {
1110 tracing::warn!(
1111 user = %identity_name,
1112 role = %role,
1113 tool = tool_name,
1114 host = host.unwrap_or("-"),
1115 "RBAC denied"
1116 );
1117 return Some(
1118 RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
1119 .into_response(),
1120 );
1121 }
1122
1123 let args = params.get("arguments").and_then(|a| a.as_object());
1124 let strict = policy.strict_argument_names(role, tool_name);
1125 if let Some(args) = args {
1126 for (arg_key, arg_val) in args {
1127 if let Some(ref permitted) = strict
1128 && let Some(resp) = check_strict_argument(
1129 identity_name,
1130 role,
1131 tool_name,
1132 permitted,
1133 arg_key,
1134 arg_val,
1135 )
1136 {
1137 return Some(resp);
1138 }
1139 if let Some(resp) =
1140 check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
1141 {
1142 return Some(resp);
1143 }
1144 }
1145 }
1146 check_required_arguments(policy, identity_name, role, tool_name, args)
1147}
1148
1149fn check_strict_argument(
1154 identity_name: &str,
1155 role: &str,
1156 tool_name: &str,
1157 permitted: &[&str],
1158 arg_key: &str,
1159 arg_val: &serde_json::Value,
1160) -> Option<Response> {
1161 if !permitted.contains(&arg_key) {
1162 tracing::warn!(
1163 user = %identity_name,
1164 role = %role,
1165 tool = tool_name,
1166 argument = arg_key,
1167 "unknown argument rejected by strict allowlist"
1168 );
1169 return Some(
1170 RmcpServerKitError::Rbac(format!(
1171 "argument '{arg_key}' is not permitted for tool '{tool_name}'"
1172 ))
1173 .into_response(),
1174 );
1175 }
1176 if arg_val.is_object() || arg_val.is_array() {
1177 tracing::warn!(
1178 user = %identity_name,
1179 role = %role,
1180 tool = tool_name,
1181 argument = arg_key,
1182 value_type = json_value_type(arg_val),
1183 "structured argument rejected by strict allowlist"
1184 );
1185 return Some(
1186 RmcpServerKitError::Rbac(format!(
1187 "argument '{arg_key}' must not be an object or array for tool '{tool_name}'"
1188 ))
1189 .into_response(),
1190 );
1191 }
1192 None
1193}
1194
1195fn check_required_arguments(
1203 policy: &RbacPolicy,
1204 identity_name: &str,
1205 role: &str,
1206 tool_name: &str,
1207 args: Option<&serde_json::Map<String, serde_json::Value>>,
1208) -> Option<Response> {
1209 let missing = policy.missing_required_argument(role, tool_name, args)?;
1210 tracing::warn!(
1211 user = %identity_name,
1212 role = %role,
1213 tool = tool_name,
1214 argument = missing,
1215 "required argument missing"
1216 );
1217 Some(
1218 RmcpServerKitError::Rbac(format!(
1219 "argument '{missing}' is required for tool '{tool_name}'"
1220 ))
1221 .into_response(),
1222 )
1223}
1224
1225fn check_argument(
1226 policy: &RbacPolicy,
1227 identity_name: &str,
1228 role: &str,
1229 tool_name: &str,
1230 arg_key: &str,
1231 arg_val: &serde_json::Value,
1232) -> Option<Response> {
1233 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1234 return None;
1235 }
1236 let Some(val_str) = arg_val.as_str() else {
1237 tracing::warn!(
1243 user = %identity_name,
1244 role = %role,
1245 tool = tool_name,
1246 argument = arg_key,
1247 value_type = json_value_type(arg_val),
1248 "non-string argument rejected by allowlist"
1249 );
1250 return Some(
1251 RmcpServerKitError::Rbac(format!(
1252 "argument '{arg_key}' must be a string for tool '{tool_name}'"
1253 ))
1254 .into_response(),
1255 );
1256 };
1257 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1258 return None;
1259 }
1260 tracing::warn!(
1265 user = %identity_name,
1266 role = %role,
1267 tool = tool_name,
1268 argument = arg_key,
1269 arg_hmac = %policy.redact_arg(val_str),
1270 "argument not in allowlist"
1271 );
1272 Some(
1273 RmcpServerKitError::Rbac(format!(
1274 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1275 ))
1276 .into_response(),
1277 )
1278}
1279
1280fn json_value_type(v: &serde_json::Value) -> &'static str {
1281 match v {
1282 serde_json::Value::Null => "null",
1283 serde_json::Value::Bool(_) => "bool",
1284 serde_json::Value::Number(_) => "number",
1285 serde_json::Value::String(_) => "string",
1286 serde_json::Value::Array(_) => "array",
1287 serde_json::Value::Object(_) => "object",
1288 }
1289}
1290
1291fn glob_match(pattern: &str, text: &str) -> bool {
1301 let parts: Vec<&str> = pattern.split('*').collect();
1302 if parts.len() == 1 {
1303 return pattern == text;
1305 }
1306
1307 let pos = if let Some(&first) = parts.first()
1309 && !first.is_empty()
1310 {
1311 if !text.starts_with(first) {
1312 return false;
1313 }
1314 first.len()
1315 } else {
1316 0
1317 };
1318
1319 if let Some(&last) = parts.last()
1321 && !last.is_empty()
1322 {
1323 if !text.get(pos..).unwrap_or_default().ends_with(last) {
1324 return false;
1325 }
1326 let end = text.len() - last.len();
1328 if pos > end {
1329 return false;
1330 }
1331 let middle = text.get(pos..end).unwrap_or_default();
1333 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1334 return match_middle(middle, middle_parts);
1335 }
1336
1337 let middle = text.get(pos..).unwrap_or_default();
1339 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1340 match_middle(middle, middle_parts)
1341}
1342
1343fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1345 for part in parts {
1346 if part.is_empty() {
1347 continue;
1348 }
1349 if let Some(idx) = text.find(part) {
1350 text = text.get(idx + part.len()..).unwrap_or_default();
1351 } else {
1352 return false;
1353 }
1354 }
1355 true
1356}
1357
1358impl RbacConfig {
1359 pub fn apply_env_overrides(
1392 &mut self,
1393 ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1394 let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1395 let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1396 match (direct, file) {
1397 (None, None) => Ok(Vec::new()),
1398 (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1399 "{} and {} must not both be set",
1400 crate::config::RBAC_REDACTION_SALT_ENV,
1401 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1402 ))),
1403 (Some(value), None) => {
1404 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1405 self.redaction_salt = Some(SecretString::from(value));
1406 Ok(vec![crate::config::secret_env_report(
1407 crate::config::RBAC_REDACTION_SALT_ENV,
1408 "rbac.redaction_salt",
1409 crate::config::EnvOverrideSource::Env,
1410 )])
1411 }
1412 (None, Some(path)) => {
1413 let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1414 RmcpServerKitError::Config(format!(
1415 "failed to read {} file {path:?}: {error}",
1416 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1417 ))
1418 })?;
1419 let secret = normalize_text_secret_file(secret);
1420 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1421 self.redaction_salt = Some(SecretString::from(secret));
1422 Ok(vec![crate::config::secret_env_report(
1423 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1424 "rbac.redaction_salt",
1425 crate::config::EnvOverrideSource::File,
1426 )])
1427 }
1428 }
1429 }
1430}
1431
1432fn normalize_text_secret_file(mut secret: String) -> String {
1433 if secret.ends_with("\r\n") {
1434 secret.truncate(secret.len() - 2);
1435 } else if secret.ends_with('\n') || secret.ends_with('\r') {
1436 secret.truncate(secret.len() - 1);
1437 }
1438 secret
1439}
1440
1441fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1442 if value.trim().is_empty() {
1443 return Err(RmcpServerKitError::Config(format!(
1444 "{env_var} must not be empty or whitespace-only"
1445 )));
1446 }
1447 Ok(())
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452 use std::net::IpAddr;
1453
1454 use super::*;
1455 use crate::transport::RateLimitKey;
1456
1457 fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1458 temp_env::with_vars(
1459 [
1460 (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1461 (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1462 ]
1463 .into_iter()
1464 .chain(vars.iter().copied())
1465 .collect::<Vec<_>>(),
1466 f,
1467 )
1468 }
1469
1470 #[test]
1471 fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1472 with_rbac_env(
1473 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1474 || {
1475 let mut cfg = RbacConfig::default();
1476 let report = cfg.apply_env_overrides().unwrap();
1477 assert!(cfg.redaction_salt.is_some());
1478 assert_eq!(report.len(), 1);
1479 assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1480 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1481 assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1482 assert!(report[0].value.is_none());
1483 assert!(!format!("{report:?}").contains("s3cret"));
1484 },
1485 );
1486 }
1487
1488 #[test]
1489 fn e7_redaction_salt_value_and_file_conflict_fails() {
1490 with_rbac_env(
1491 &[
1492 (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1493 (
1494 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1495 Some("/tmp/secret-file"),
1496 ),
1497 ],
1498 || {
1499 let mut cfg = RbacConfig::default();
1500 let err = cfg.apply_env_overrides().unwrap_err();
1501 let msg = err.to_string();
1502 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1503 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1504 },
1505 );
1506 }
1507
1508 #[test]
1509 fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1510 let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1511 let direct_redaction = redaction_from_direct_salt("same-salt");
1512
1513 assert_eq!(file_redaction, direct_redaction);
1514 assert_eq!(report.len(), 1);
1515 assert_eq!(
1516 report[0].env_var,
1517 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1518 );
1519 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1520 assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1521 assert!(report[0].value.is_none());
1522 }
1523
1524 #[test]
1525 fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1526 let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1527 assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1528
1529 let (spaced_redaction, _) = redaction_from_file(" same-salt \n").expect("spaced salt");
1530 assert_eq!(
1531 spaced_redaction,
1532 redaction_from_direct_salt(" same-salt ")
1533 );
1534 assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1535 }
1536
1537 #[derive(Clone, Default)]
1538 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
1539
1540 impl CapturedLogs {
1541 fn contents(&self) -> String {
1542 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
1543 String::from_utf8(bytes).unwrap_or_default()
1544 }
1545 }
1546
1547 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
1548
1549 impl std::io::Write for CapturedLogsWriter {
1550 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1551 if let Ok(mut guard) = self.0.lock() {
1552 guard.extend_from_slice(buf);
1553 }
1554 Ok(buf.len())
1555 }
1556
1557 fn flush(&mut self) -> std::io::Result<()> {
1558 Ok(())
1559 }
1560 }
1561
1562 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
1563 type Writer = CapturedLogsWriter;
1564
1565 fn make_writer(&'a self) -> Self::Writer {
1566 CapturedLogsWriter(Arc::clone(&self.0))
1567 }
1568 }
1569
1570 fn allowlist_warning_policy(allowlist: ArgumentAllowlist) -> RbacConfig {
1571 RbacConfig::with_roles(vec![
1572 RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1573 .with_argument_allowlists(vec![allowlist]),
1574 ])
1575 }
1576
1577 fn capture_policy_construction_logs(config: &RbacConfig) -> String {
1578 let logs = CapturedLogs::default();
1579 let subscriber = tracing_subscriber::fmt()
1580 .with_writer(logs.clone())
1581 .with_ansi(false)
1582 .without_time()
1583 .finish();
1584 let _guard = tracing::subscriber::set_default(subscriber);
1585
1586 let _policy = RbacPolicy::new(config);
1587 logs.contents()
1588 }
1589
1590 #[test]
1591 fn optional_non_empty_argument_allowlist_warns_once_at_policy_construction() {
1592 let config =
1593 allowlist_warning_policy(ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]));
1594
1595 let logs = capture_policy_construction_logs(&config);
1596
1597 assert_eq!(
1598 logs.matches("optional argument allowlist may fail open")
1599 .count(),
1600 1,
1601 "exactly one warning expected for one optional non-empty allowlist: {logs}"
1602 );
1603 assert!(logs.contains("run"), "warning must name the tool: {logs}");
1604 assert!(
1605 logs.contains("cmd"),
1606 "warning must name the argument: {logs}"
1607 );
1608 }
1609
1610 #[test]
1611 fn required_argument_allowlist_does_not_warn_at_policy_construction() {
1612 let config = allowlist_warning_policy(
1613 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]).with_required(true),
1614 );
1615
1616 let logs = capture_policy_construction_logs(&config);
1617
1618 assert!(
1619 !logs.contains("optional argument allowlist may fail open"),
1620 "required allowlist must not warn: {logs}"
1621 );
1622 }
1623
1624 #[test]
1625 fn new_required_sets_required_and_preserves_value_allowlist_behavior() {
1626 let optional = ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]);
1627 let required = ArgumentAllowlist::new_required("run", "cmd", vec!["ls".into()]);
1628
1629 assert_eq!(required.tool, optional.tool);
1630 assert_eq!(required.argument, optional.argument);
1631 assert_eq!(required.allowed, optional.allowed);
1632 assert!(required.required);
1633 assert!(!optional.required);
1634
1635 let optional_policy = RbacPolicy::new(&allowlist_warning_policy(optional));
1636 let required_policy = RbacPolicy::new(&allowlist_warning_policy(required));
1637 assert_eq!(
1638 optional_policy.argument_allowed("viewer", "run", "cmd", "ls -la"),
1639 required_policy.argument_allowed("viewer", "run", "cmd", "ls -la")
1640 );
1641 assert_eq!(
1642 optional_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /"),
1643 required_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /")
1644 );
1645 }
1646
1647 #[test]
1648 fn blank_redaction_salt_env_values_fail_closed() {
1649 for value in ["", "\n", " "] {
1650 with_rbac_env(
1651 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1652 || {
1653 let mut cfg = RbacConfig::default();
1654 let err = cfg.apply_env_overrides().unwrap_err();
1655 assert!(
1656 err.to_string()
1657 .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1658 );
1659 },
1660 );
1661 }
1662 }
1663
1664 #[test]
1665 fn blank_redaction_salt_file_values_fail_closed() {
1666 for value in ["", "\n", "\r\n", " \n"] {
1667 let err = redaction_from_file(value).unwrap_err();
1668 assert!(
1669 err.to_string()
1670 .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1671 );
1672 }
1673 }
1674
1675 fn redaction_from_direct_salt(salt: &str) -> String {
1676 RbacPolicy::new(&RbacConfig {
1677 redaction_salt: Some(SecretString::from(salt.to_owned())),
1678 ..RbacConfig::default()
1679 })
1680 .redact_arg("same-argument")
1681 }
1682
1683 fn redaction_from_file(
1684 content: &str,
1685 ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1686 let path = std::env::temp_dir().join(format!(
1687 "rmcp-server-kit-redaction-salt-{}.txt",
1688 std::time::SystemTime::now()
1689 .duration_since(std::time::UNIX_EPOCH)
1690 .expect("clock after epoch")
1691 .as_nanos()
1692 ));
1693 std::fs::write(&path, content).expect("write salt file");
1694 let path_string = path.to_string_lossy().to_string();
1695 let result = with_rbac_env(
1696 &[(
1697 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1698 Some(path_string.as_str()),
1699 )],
1700 || {
1701 let mut cfg = RbacConfig::default();
1702 let report = cfg.apply_env_overrides()?;
1703 let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1704 Ok((redaction, report))
1705 },
1706 );
1707 std::fs::remove_file(path).expect("remove salt file");
1708 result
1709 }
1710
1711 #[test]
1716 fn tool_limiter_burst_allows_initial_spike() {
1717 let limiter = build_tool_rate_limiter_with_policy(2, Some(4), KeyEvictionPolicy::default());
1718 let ip = RateLimitKey::Ip("10.9.9.9".parse::<IpAddr>().unwrap());
1719 for i in 0..4 {
1720 assert!(
1721 limiter.check_key(&ip).is_ok(),
1722 "burst request {i} should pass"
1723 );
1724 }
1725 assert!(
1726 limiter.check_key(&ip).is_err(),
1727 "request 5 must exceed the burst bucket"
1728 );
1729 }
1730
1731 #[test]
1733 fn tool_limiter_deny_sets_retry_after() {
1734 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
1735 let ip = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1736 assert!(enforce_rate_limit(Some(&limiter), Some(&ip)).is_none());
1737 let resp = enforce_rate_limit(Some(&limiter), Some(&ip))
1738 .expect("second call within the window must deny");
1739 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1740 let retry_after = resp
1741 .headers()
1742 .get(axum::http::header::RETRY_AFTER)
1743 .expect("Retry-After present")
1744 .to_str()
1745 .unwrap()
1746 .parse::<u64>()
1747 .unwrap();
1748 assert!(retry_after >= 1, "delta-seconds must be >= 1");
1749 }
1750
1751 #[test]
1752 fn tool_limiter_capacity_full_returns_503_without_retry_after() {
1753 let limiter = build_tool_rate_limiter_with_bounds(
1754 10,
1755 None,
1756 1,
1757 Duration::from_hours(1),
1758 KeyEvictionPolicy::RejectNew,
1759 );
1760 let established = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1761 let unseen = RateLimitKey::Ip("10.8.8.9".parse::<IpAddr>().unwrap());
1762 assert!(enforce_rate_limit(Some(&limiter), Some(&established)).is_none());
1763
1764 let resp = enforce_rate_limit(Some(&limiter), Some(&unseen))
1765 .expect("unseen key must be rejected at capacity");
1766
1767 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
1768 assert!(
1769 resp.headers()
1770 .get(axum::http::header::RETRY_AFTER)
1771 .is_none()
1772 );
1773 }
1774
1775 fn test_policy() -> RbacPolicy {
1776 RbacPolicy::new(&RbacConfig {
1777 enabled: true,
1778 roles: vec![
1779 RoleConfig {
1780 name: "viewer".into(),
1781 description: Some("Read-only".into()),
1782 allow: vec![
1783 "list_hosts".into(),
1784 "resource_list".into(),
1785 "resource_inspect".into(),
1786 "resource_logs".into(),
1787 "system_info".into(),
1788 ],
1789 deny: vec![],
1790 hosts: vec!["*".into()],
1791 argument_allowlists: vec![],
1792 },
1793 RoleConfig {
1794 name: "deploy".into(),
1795 description: Some("Lifecycle management".into()),
1796 allow: vec![
1797 "list_hosts".into(),
1798 "resource_list".into(),
1799 "resource_run".into(),
1800 "resource_start".into(),
1801 "resource_stop".into(),
1802 "resource_restart".into(),
1803 "resource_logs".into(),
1804 "image_pull".into(),
1805 ],
1806 deny: vec!["resource_delete".into(), "resource_exec".into()],
1807 hosts: vec!["web-*".into(), "api-*".into()],
1808 argument_allowlists: vec![],
1809 },
1810 RoleConfig {
1811 name: "ops".into(),
1812 description: Some("Full access".into()),
1813 allow: vec!["*".into()],
1814 deny: vec![],
1815 hosts: vec!["*".into()],
1816 argument_allowlists: vec![],
1817 },
1818 RoleConfig {
1819 name: "restricted-exec".into(),
1820 description: Some("Exec with argument allowlist".into()),
1821 allow: vec!["resource_exec".into()],
1822 deny: vec![],
1823 hosts: vec!["dev-*".into()],
1824 argument_allowlists: vec![ArgumentAllowlist {
1825 tool: "resource_exec".into(),
1826 argument: "cmd".into(),
1827 allowed: vec![
1828 "sh".into(),
1829 "bash".into(),
1830 "cat".into(),
1831 "ls".into(),
1832 "ps".into(),
1833 ],
1834 required: false,
1835 deny_unknown_arguments: false,
1836 }],
1837 },
1838 ],
1839 redaction_salt: None,
1840 })
1841 }
1842
1843 #[test]
1846 fn glob_exact_match() {
1847 assert!(glob_match("web-prod-1", "web-prod-1"));
1848 assert!(!glob_match("web-prod-1", "web-prod-2"));
1849 }
1850
1851 #[test]
1852 fn glob_star_suffix() {
1853 assert!(glob_match("web-*", "web-prod-1"));
1854 assert!(glob_match("web-*", "web-staging"));
1855 assert!(!glob_match("web-*", "api-prod"));
1856 }
1857
1858 #[test]
1859 fn glob_star_prefix() {
1860 assert!(glob_match("*-prod", "web-prod"));
1861 assert!(glob_match("*-prod", "api-prod"));
1862 assert!(!glob_match("*-prod", "web-staging"));
1863 }
1864
1865 #[test]
1866 fn glob_star_middle() {
1867 assert!(glob_match("web-*-prod", "web-us-prod"));
1868 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1869 assert!(!glob_match("web-*-prod", "web-staging"));
1870 }
1871
1872 #[test]
1873 fn glob_star_only() {
1874 assert!(glob_match("*", "anything"));
1875 assert!(glob_match("*", ""));
1876 }
1877
1878 #[test]
1879 fn glob_multiple_stars() {
1880 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1881 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1882 }
1883
1884 #[test]
1889 fn glob_match_multibyte_utf8() {
1890 assert!(glob_match("hé*llo", "héllo"));
1891 assert!(glob_match("*ö*", "wörld"));
1892 assert!(glob_match("über*", "übermensch"));
1893 assert!(glob_match("*界", "世界"));
1894 assert!(!glob_match("hé*llo", "hello"));
1895 assert!(!glob_match("界*", "世界"));
1896 assert!(glob_match("世*界", "世界"));
1897 }
1898
1899 #[test]
1911 fn glob_prefix_and_suffix_meet_exactly() {
1912 assert!(glob_match("ab*cd", "abcd"));
1915 }
1916
1917 #[test]
1922 fn glob_middle_segment_required_with_suffix() {
1923 assert!(!glob_match("a*b*c", "axyc"));
1928 }
1929
1930 #[test]
1936 fn glob_match_middle_advances_past_matched_part() {
1937 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1942 }
1943
1944 #[test]
1949 fn glob_match_middle_uses_addition_not_multiplication() {
1950 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1954 }
1955
1956 #[test]
1965 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1966 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1974 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1975 "run-*",
1976 "cmd",
1977 vec!["ls".into()],
1978 )]);
1979 let mut config = RbacConfig::with_roles(vec![role]);
1980 config.enabled = true;
1981 let policy = RbacPolicy::new(&config);
1982 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1983 }
1984
1985 #[test]
1988 fn disabled_policy_allows_everything() {
1989 let policy = RbacPolicy::new(&RbacConfig {
1990 enabled: false,
1991 roles: vec![],
1992 redaction_salt: None,
1993 });
1994 assert_eq!(
1995 policy.check("nonexistent", "resource_delete", "any-host"),
1996 RbacDecision::Allow
1997 );
1998 }
1999
2000 #[test]
2001 fn unknown_role_denied() {
2002 let policy = test_policy();
2003 assert_eq!(
2004 policy.check("unknown", "resource_list", "web-prod-1"),
2005 RbacDecision::Deny
2006 );
2007 }
2008
2009 #[test]
2010 fn viewer_allowed_read_ops() {
2011 let policy = test_policy();
2012 assert_eq!(
2013 policy.check("viewer", "resource_list", "web-prod-1"),
2014 RbacDecision::Allow
2015 );
2016 assert_eq!(
2017 policy.check("viewer", "system_info", "db-host"),
2018 RbacDecision::Allow
2019 );
2020 }
2021
2022 #[test]
2023 fn viewer_denied_write_ops() {
2024 let policy = test_policy();
2025 assert_eq!(
2026 policy.check("viewer", "resource_run", "web-prod-1"),
2027 RbacDecision::Deny
2028 );
2029 assert_eq!(
2030 policy.check("viewer", "resource_delete", "web-prod-1"),
2031 RbacDecision::Deny
2032 );
2033 }
2034
2035 #[test]
2036 fn deploy_allowed_on_matching_hosts() {
2037 let policy = test_policy();
2038 assert_eq!(
2039 policy.check("deploy", "resource_run", "web-prod-1"),
2040 RbacDecision::Allow
2041 );
2042 assert_eq!(
2043 policy.check("deploy", "resource_start", "api-staging"),
2044 RbacDecision::Allow
2045 );
2046 }
2047
2048 #[test]
2049 fn deploy_denied_on_non_matching_host() {
2050 let policy = test_policy();
2051 assert_eq!(
2052 policy.check("deploy", "resource_run", "db-prod-1"),
2053 RbacDecision::Deny
2054 );
2055 }
2056
2057 #[test]
2058 fn deny_overrides_allow() {
2059 let policy = test_policy();
2060 assert_eq!(
2061 policy.check("deploy", "resource_delete", "web-prod-1"),
2062 RbacDecision::Deny
2063 );
2064 assert_eq!(
2065 policy.check("deploy", "resource_exec", "web-prod-1"),
2066 RbacDecision::Deny
2067 );
2068 }
2069
2070 #[test]
2071 fn ops_wildcard_allows_everything() {
2072 let policy = test_policy();
2073 assert_eq!(
2074 policy.check("ops", "resource_delete", "any-host"),
2075 RbacDecision::Allow
2076 );
2077 assert_eq!(
2078 policy.check("ops", "secret_create", "db-host"),
2079 RbacDecision::Allow
2080 );
2081 }
2082
2083 #[test]
2086 fn host_visible_respects_globs() {
2087 let policy = test_policy();
2088 assert!(policy.host_visible("deploy", "web-prod-1"));
2089 assert!(policy.host_visible("deploy", "api-staging"));
2090 assert!(!policy.host_visible("deploy", "db-prod-1"));
2091 assert!(policy.host_visible("ops", "anything"));
2092 assert!(policy.host_visible("viewer", "anything"));
2093 }
2094
2095 #[test]
2096 fn host_visible_unknown_role() {
2097 let policy = test_policy();
2098 assert!(!policy.host_visible("unknown", "web-prod-1"));
2099 }
2100
2101 #[test]
2102 fn host_matching_is_ascii_case_insensitive() {
2103 let policy = test_policy();
2104 assert!(policy.host_visible("deploy", "WEB-PROD-1"));
2105 assert!(policy.host_visible("deploy", "Web-Prod-1"));
2106 assert!(policy.host_visible("deploy", "API-Staging"));
2107 assert!(!policy.host_visible("deploy", "DB-PROD-1"));
2108 }
2109
2110 #[test]
2111 fn check_host_matching_is_ascii_case_insensitive() {
2112 let policy = test_policy();
2113 assert_eq!(
2114 policy.check("deploy", "resource_run", "WEB-PROD-1"),
2115 RbacDecision::Allow
2116 );
2117 assert_eq!(
2118 policy.check("deploy", "resource_run", "DB-PROD-1"),
2119 RbacDecision::Deny
2120 );
2121 }
2122
2123 #[test]
2124 fn check_operation_names_remain_case_sensitive() {
2125 let policy = test_policy();
2126 assert_eq!(
2127 policy.check("deploy", "RESOURCE_RUN", "web-prod-1"),
2128 RbacDecision::Deny,
2129 "host normalization must not leak into operation matching"
2130 );
2131 }
2132
2133 #[test]
2134 fn tool_glob_matching_remains_case_sensitive() {
2135 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2138 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2139 "resource_*",
2140 "cmd",
2141 vec!["ls".into()],
2142 )]);
2143 let policy = RbacPolicy::new(&RbacConfig::with_roles(vec![role]));
2144
2145 assert!(policy.has_argument_allowlist("viewer", "resource_exec", "cmd"));
2146 assert!(
2147 !policy.has_argument_allowlist("viewer", "RESOURCE_EXEC", "cmd"),
2148 "tool patterns must not match case-insensitively"
2149 );
2150 assert!(!policy.argument_allowed("viewer", "resource_exec", "cmd", "rm"));
2151 }
2152
2153 #[test]
2156 fn argument_allowed_no_allowlist() {
2157 let policy = test_policy();
2158 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
2160 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
2161 }
2162
2163 #[test]
2164 fn argument_allowed_with_allowlist() {
2165 let policy = test_policy();
2166 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
2167 assert!(policy.argument_allowed(
2168 "restricted-exec",
2169 "resource_exec",
2170 "cmd",
2171 "bash -c 'echo hi'"
2172 ));
2173 assert!(policy.argument_allowed(
2174 "restricted-exec",
2175 "resource_exec",
2176 "cmd",
2177 "cat /etc/hosts"
2178 ));
2179 assert!(policy.argument_allowed(
2180 "restricted-exec",
2181 "resource_exec",
2182 "cmd",
2183 "/usr/bin/ls -la"
2184 ));
2185 }
2186
2187 #[test]
2188 fn argument_denied_not_in_allowlist() {
2189 let policy = test_policy();
2190 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
2191 assert!(!policy.argument_allowed(
2192 "restricted-exec",
2193 "resource_exec",
2194 "cmd",
2195 "python3 exploit.py"
2196 ));
2197 assert!(!policy.argument_allowed(
2198 "restricted-exec",
2199 "resource_exec",
2200 "cmd",
2201 "/usr/bin/curl evil.com"
2202 ));
2203 }
2204
2205 #[test]
2206 fn argument_denied_unknown_role() {
2207 let policy = test_policy();
2208 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
2209 }
2210
2211 fn strict_test_policy(allowlists: Vec<ArgumentAllowlist>) -> RbacPolicy {
2214 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2215 .with_argument_allowlists(allowlists);
2216 let mut config = RbacConfig::with_roles(vec![role]);
2217 config.enabled = true;
2218 RbacPolicy::new(&config)
2219 }
2220
2221 fn tool_call(args: serde_json::Value) -> serde_json::Value {
2222 let mut params = serde_json::Map::new();
2223 params.insert(
2224 "name".to_owned(),
2225 serde_json::Value::String("run".to_owned()),
2226 );
2227 params.insert("arguments".to_owned(), args);
2228 serde_json::Value::Object(params)
2229 }
2230
2231 #[test]
2232 fn unknown_arguments_are_admitted_when_strict_mode_is_off() {
2233 let policy = strict_test_policy(vec![ArgumentAllowlist::new(
2234 "run",
2235 "cmd",
2236 vec!["ls".into()],
2237 )]);
2238 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2239 assert!(
2240 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2241 "default behaviour must be unchanged: unnamed arguments pass"
2242 );
2243 }
2244
2245 #[test]
2246 fn strict_mode_rejects_unknown_arguments() {
2247 let policy = strict_test_policy(vec![
2248 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2249 .with_deny_unknown_arguments(true),
2250 ]);
2251 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2252 assert!(
2253 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2254 "an argument no allowlist names must be denied under strict mode"
2255 );
2256
2257 let permitted = tool_call(serde_json::json!({ "cmd": "ls" }));
2258 assert!(
2259 enforce_tool_policy(&policy, "u", "viewer", &permitted).is_none(),
2260 "an allowlisted argument must still pass"
2261 );
2262 }
2263
2264 #[test]
2265 fn strict_mode_rejects_structured_argument_values() {
2266 let policy = strict_test_policy(vec![
2267 ArgumentAllowlist::new("run", "cmd", vec![]).with_deny_unknown_arguments(true),
2268 ]);
2269 for shape in [
2270 serde_json::json!({ "nested": "x" }),
2271 serde_json::json!(["x"]),
2272 ] {
2273 let params = tool_call(serde_json::json!({ "cmd": shape }));
2274 assert!(
2275 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2276 "object/array values cannot be constrained and must be denied"
2277 );
2278 }
2279 }
2280
2281 #[test]
2282 fn strict_mode_permits_the_union_of_matching_allowlists() {
2283 let policy = strict_test_policy(vec![
2286 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2287 .with_deny_unknown_arguments(true),
2288 ArgumentAllowlist::new("run", "host", vec![]),
2289 ]);
2290 let params = tool_call(serde_json::json!({ "cmd": "ls", "host": "dev-1" }));
2291 assert!(
2292 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2293 "every matching allowlist's argument must remain permitted"
2294 );
2295 }
2296
2297 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
2306 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2307 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
2308 let mut config = RbacConfig::with_roles(vec![role]);
2309 config.enabled = true;
2310 RbacPolicy::new(&config)
2311 }
2312
2313 #[test]
2314 fn argument_allowed_matches_quoted_path_with_spaces() {
2315 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
2316 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2317 }
2318
2319 #[test]
2320 fn argument_allowed_matches_basename_of_quoted_path() {
2321 let policy = shlex_policy(vec!["my tool".into()]);
2322 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2323 }
2324
2325 #[test]
2326 fn argument_allowed_fails_closed_on_unbalanced_quote() {
2327 let policy = shlex_policy(vec!["unbalanced".into()]);
2328 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
2329 }
2330
2331 #[test]
2332 fn argument_allowed_fails_closed_on_empty_string() {
2333 let policy = shlex_policy(vec![String::new()]);
2334 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
2335 }
2336
2337 #[test]
2338 fn argument_allowed_handles_single_quoted_executable() {
2339 let policy = shlex_policy(vec!["/bin/sh".into()]);
2340 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
2341 }
2342
2343 #[test]
2344 fn argument_allowed_handles_tab_separator() {
2345 let policy = shlex_policy(vec!["ls".into()]);
2346 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
2347 }
2348
2349 #[test]
2350 fn argument_allowed_plain_token_unchanged() {
2351 let policy = shlex_policy(vec!["ls".into()]);
2352 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
2353 }
2354
2355 #[test]
2361 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
2362 let policy = shlex_policy(vec![String::new()]);
2366 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
2367 }
2368
2369 #[test]
2370 fn argument_allowed_quoted_literal_token_no_longer_matches() {
2371 let policy = shlex_policy(vec!["'bash'".into()]);
2377 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
2378 }
2379
2380 #[test]
2381 fn argument_allowed_backslash_literal_token_no_longer_matches() {
2382 let policy = shlex_policy(vec![r"foo\bar".into()]);
2387 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
2388 }
2389
2390 #[test]
2391 fn argument_allowed_windows_path_no_longer_matches() {
2392 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
2397 assert!(!policy.argument_allowed(
2398 "viewer",
2399 "run",
2400 "cmd",
2401 r"C:\Windows\System32\cmd.exe /c dir"
2402 ));
2403 }
2404
2405 #[test]
2408 fn host_patterns_returns_globs() {
2409 let policy = test_policy();
2410 assert_eq!(
2411 policy.host_patterns("deploy"),
2412 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
2413 );
2414 assert_eq!(
2415 policy.host_patterns("ops"),
2416 Some(vec!["*".to_owned()].as_slice())
2417 );
2418 assert!(policy.host_patterns("nonexistent").is_none());
2419 }
2420
2421 #[test]
2424 fn check_operation_allows_without_host() {
2425 let policy = test_policy();
2426 assert_eq!(
2427 policy.check_operation("deploy", "resource_run"),
2428 RbacDecision::Allow
2429 );
2430 assert_eq!(
2432 policy.check("deploy", "resource_run", "db-prod-1"),
2433 RbacDecision::Deny
2434 );
2435 }
2436
2437 #[test]
2438 fn check_operation_deny_overrides() {
2439 let policy = test_policy();
2440 assert_eq!(
2441 policy.check_operation("deploy", "resource_delete"),
2442 RbacDecision::Deny
2443 );
2444 }
2445
2446 #[test]
2447 fn check_operation_unknown_role() {
2448 let policy = test_policy();
2449 assert_eq!(
2450 policy.check_operation("unknown", "resource_list"),
2451 RbacDecision::Deny
2452 );
2453 }
2454
2455 #[test]
2456 fn check_operation_disabled() {
2457 let policy = RbacPolicy::new(&RbacConfig {
2458 enabled: false,
2459 roles: vec![],
2460 redaction_salt: None,
2461 });
2462 assert_eq!(
2463 policy.check_operation("nonexistent", "anything"),
2464 RbacDecision::Allow
2465 );
2466 }
2467
2468 #[test]
2471 fn current_role_returns_none_outside_scope() {
2472 assert!(current_role().is_none());
2473 }
2474
2475 #[test]
2476 fn current_identity_returns_none_outside_scope() {
2477 assert!(current_identity().is_none());
2478 }
2479
2480 use axum::{
2483 body::Body,
2484 http::{Method, Request, StatusCode},
2485 };
2486 use tower::ServiceExt as _;
2487
2488 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
2489 serde_json::json!({
2490 "jsonrpc": "2.0",
2491 "id": 1,
2492 "method": "tools/call",
2493 "params": {
2494 "name": tool,
2495 "arguments": args
2496 }
2497 })
2498 .to_string()
2499 }
2500
2501 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
2502 axum::Router::new()
2503 .route("/mcp", axum::routing::post(|| async { "ok" }))
2504 .layer(axum::middleware::from_fn(move |req, next| {
2505 let p = Arc::clone(&policy);
2506 rbac_middleware(p, None, req, next)
2507 }))
2508 }
2509
2510 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
2511 axum::Router::new()
2512 .route("/mcp", axum::routing::post(|| async { "ok" }))
2513 .layer(axum::middleware::from_fn(
2514 move |mut req: Request<Body>, next: Next| {
2515 let p = Arc::clone(&policy);
2516 let id = identity.clone();
2517 async move {
2518 req.extensions_mut().insert(id);
2519 rbac_middleware(p, None, req, next).await
2520 }
2521 },
2522 ))
2523 }
2524
2525 #[cfg(feature = "metrics")]
2529 #[tokio::test]
2530 async fn tool_limiter_deny_increments_counter() {
2531 use axum::extract::ConnectInfo;
2532
2533 let policy = Arc::new(test_policy());
2534 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
2535 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
2536 let identity = AuthIdentity {
2537 method: crate::auth::AuthMethod::BearerToken,
2538 name: "alice".into(),
2539 role: "viewer".into(),
2540 raw_token: None,
2541 sub: None,
2542 };
2543 let app = {
2544 let metrics = Arc::clone(&metrics);
2545 axum::Router::new()
2546 .route("/mcp", axum::routing::post(|| async { "ok" }))
2547 .layer(axum::middleware::from_fn(
2548 move |mut req: Request<Body>, next: Next| {
2549 let p = Arc::clone(&policy);
2550 let l = Arc::clone(&limiter);
2551 let id = identity.clone();
2552 let m = Arc::clone(&metrics);
2553 async move {
2554 req.extensions_mut().insert(id);
2555 req.extensions_mut().insert(m);
2556 let peer: std::net::SocketAddr =
2557 "10.9.9.1:40000".parse().expect("static socket addr parses");
2558 req.extensions_mut().insert(ConnectInfo(peer));
2559 rbac_middleware(p, Some(l), req, next).await
2560 }
2561 },
2562 ))
2563 };
2564 let mk = || {
2565 Request::builder()
2566 .method(Method::POST)
2567 .uri("/mcp")
2568 .header("content-type", "application/json")
2569 .body(Body::from(tool_call_body(
2570 "resource_list",
2571 &serde_json::json!({}),
2572 )))
2573 .unwrap()
2574 };
2575 let counter = || {
2576 metrics
2577 .rate_limited_total
2578 .with_label_values(&["tool"])
2579 .get()
2580 };
2581
2582 let first = app.clone().oneshot(mk()).await.unwrap();
2583 assert_eq!(first.status(), StatusCode::OK);
2584 assert_eq!(counter(), 0, "successful call must not count");
2585
2586 let denied = app.clone().oneshot(mk()).await.unwrap();
2587 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
2588 assert_eq!(counter(), 1, "deny must increment the tool label");
2589 }
2590
2591 #[tokio::test]
2592 async fn middleware_passes_non_post() {
2593 let policy = Arc::new(test_policy());
2594 let app = rbac_router(policy);
2595 let req = Request::builder()
2597 .method(Method::GET)
2598 .uri("/mcp")
2599 .body(Body::empty())
2600 .unwrap();
2601 let resp = app.oneshot(req).await.unwrap();
2604 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
2605 }
2606
2607 #[tokio::test]
2608 async fn middleware_denies_without_identity() {
2609 let policy = Arc::new(test_policy());
2610 let app = rbac_router(policy);
2611 let body = tool_call_body("resource_list", &serde_json::json!({}));
2612 let req = Request::builder()
2613 .method(Method::POST)
2614 .uri("/mcp")
2615 .header("content-type", "application/json")
2616 .body(Body::from(body))
2617 .unwrap();
2618 let resp = app.oneshot(req).await.unwrap();
2619 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2620 }
2621
2622 #[tokio::test]
2623 async fn middleware_allows_permitted_tool() {
2624 let policy = Arc::new(test_policy());
2625 let id = AuthIdentity {
2626 method: crate::auth::AuthMethod::BearerToken,
2627 name: "alice".into(),
2628 role: "viewer".into(),
2629 raw_token: None,
2630 sub: None,
2631 };
2632 let app = rbac_router_with_identity(policy, id);
2633 let body = tool_call_body("resource_list", &serde_json::json!({}));
2634 let req = Request::builder()
2635 .method(Method::POST)
2636 .uri("/mcp")
2637 .header("content-type", "application/json")
2638 .body(Body::from(body))
2639 .unwrap();
2640 let resp = app.oneshot(req).await.unwrap();
2641 assert_eq!(resp.status(), StatusCode::OK);
2642 }
2643
2644 #[tokio::test]
2645 async fn middleware_denies_unpermitted_tool() {
2646 let policy = Arc::new(test_policy());
2647 let id = AuthIdentity {
2648 method: crate::auth::AuthMethod::BearerToken,
2649 name: "alice".into(),
2650 role: "viewer".into(),
2651 raw_token: None,
2652 sub: None,
2653 };
2654 let app = rbac_router_with_identity(policy, id);
2655 let body = tool_call_body("resource_delete", &serde_json::json!({}));
2656 let req = Request::builder()
2657 .method(Method::POST)
2658 .uri("/mcp")
2659 .header("content-type", "application/json")
2660 .body(Body::from(body))
2661 .unwrap();
2662 let resp = app.oneshot(req).await.unwrap();
2663 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2664 }
2665
2666 #[tokio::test]
2667 async fn middleware_passes_non_tool_call_post() {
2668 let policy = Arc::new(test_policy());
2669 let id = AuthIdentity {
2670 method: crate::auth::AuthMethod::BearerToken,
2671 name: "alice".into(),
2672 role: "viewer".into(),
2673 raw_token: None,
2674 sub: None,
2675 };
2676 let app = rbac_router_with_identity(policy, id);
2677 let body = serde_json::json!({
2679 "jsonrpc": "2.0",
2680 "id": 1,
2681 "method": "resources/list"
2682 })
2683 .to_string();
2684 let req = Request::builder()
2685 .method(Method::POST)
2686 .uri("/mcp")
2687 .header("content-type", "application/json")
2688 .body(Body::from(body))
2689 .unwrap();
2690 let resp = app.oneshot(req).await.unwrap();
2691 assert_eq!(resp.status(), StatusCode::OK);
2692 }
2693
2694 #[tokio::test]
2695 async fn middleware_enforces_argument_allowlist() {
2696 let policy = Arc::new(test_policy());
2697 let id = AuthIdentity {
2698 method: crate::auth::AuthMethod::BearerToken,
2699 name: "dev".into(),
2700 role: "restricted-exec".into(),
2701 raw_token: None,
2702 sub: None,
2703 };
2704 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
2706 let body = tool_call_body(
2707 "resource_exec",
2708 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
2709 );
2710 let req = Request::builder()
2711 .method(Method::POST)
2712 .uri("/mcp")
2713 .body(Body::from(body))
2714 .unwrap();
2715 let resp = app.oneshot(req).await.unwrap();
2716 assert_eq!(resp.status(), StatusCode::OK);
2717
2718 let app = rbac_router_with_identity(policy, id);
2720 let body = tool_call_body(
2721 "resource_exec",
2722 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
2723 );
2724 let req = Request::builder()
2725 .method(Method::POST)
2726 .uri("/mcp")
2727 .body(Body::from(body))
2728 .unwrap();
2729 let resp = app.oneshot(req).await.unwrap();
2730 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2731 }
2732
2733 #[tokio::test]
2734 async fn middleware_disabled_policy_passes_everything() {
2735 let policy = Arc::new(RbacPolicy::disabled());
2736 let app = rbac_router(policy);
2737 let body = tool_call_body("anything", &serde_json::json!({}));
2739 let req = Request::builder()
2740 .method(Method::POST)
2741 .uri("/mcp")
2742 .body(Body::from(body))
2743 .unwrap();
2744 let resp = app.oneshot(req).await.unwrap();
2745 assert_eq!(resp.status(), StatusCode::OK);
2746 }
2747
2748 #[tokio::test]
2749 async fn middleware_batch_all_allowed_passes() {
2750 let policy = Arc::new(test_policy());
2751 let id = AuthIdentity {
2752 method: crate::auth::AuthMethod::BearerToken,
2753 name: "alice".into(),
2754 role: "viewer".into(),
2755 raw_token: None,
2756 sub: None,
2757 };
2758 let app = rbac_router_with_identity(policy, id);
2759 let body = serde_json::json!([
2760 {
2761 "jsonrpc": "2.0",
2762 "id": 1,
2763 "method": "tools/call",
2764 "params": { "name": "resource_list", "arguments": {} }
2765 },
2766 {
2767 "jsonrpc": "2.0",
2768 "id": 2,
2769 "method": "tools/call",
2770 "params": { "name": "system_info", "arguments": {} }
2771 }
2772 ])
2773 .to_string();
2774 let req = Request::builder()
2775 .method(Method::POST)
2776 .uri("/mcp")
2777 .header("content-type", "application/json")
2778 .body(Body::from(body))
2779 .unwrap();
2780 let resp = app.oneshot(req).await.unwrap();
2781 assert_eq!(resp.status(), StatusCode::OK);
2782 }
2783
2784 #[tokio::test]
2785 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
2786 let policy = Arc::new(test_policy());
2787 let id = AuthIdentity {
2788 method: crate::auth::AuthMethod::BearerToken,
2789 name: "alice".into(),
2790 role: "viewer".into(),
2791 raw_token: None,
2792 sub: None,
2793 };
2794 let app = rbac_router_with_identity(policy, id);
2795 let body = serde_json::json!([
2796 {
2797 "jsonrpc": "2.0",
2798 "id": 1,
2799 "method": "tools/call",
2800 "params": { "name": "resource_list", "arguments": {} }
2801 },
2802 {
2803 "jsonrpc": "2.0",
2804 "id": 2,
2805 "method": "tools/call",
2806 "params": { "name": "resource_delete", "arguments": {} }
2807 }
2808 ])
2809 .to_string();
2810 let req = Request::builder()
2811 .method(Method::POST)
2812 .uri("/mcp")
2813 .header("content-type", "application/json")
2814 .body(Body::from(body))
2815 .unwrap();
2816 let resp = app.oneshot(req).await.unwrap();
2817 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2818 }
2819
2820 #[tokio::test]
2821 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
2822 let policy = Arc::new(test_policy());
2823 let id = AuthIdentity {
2824 method: crate::auth::AuthMethod::BearerToken,
2825 name: "dev".into(),
2826 role: "restricted-exec".into(),
2827 raw_token: None,
2828 sub: None,
2829 };
2830 let app = rbac_router_with_identity(policy, id);
2831 let body = serde_json::json!([
2832 {
2833 "jsonrpc": "2.0",
2834 "id": 1,
2835 "method": "tools/call",
2836 "params": {
2837 "name": "resource_exec",
2838 "arguments": { "cmd": "ls -la", "host": "dev-1" }
2839 }
2840 },
2841 {
2842 "jsonrpc": "2.0",
2843 "id": 2,
2844 "method": "tools/call",
2845 "params": {
2846 "name": "resource_exec",
2847 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
2848 }
2849 }
2850 ])
2851 .to_string();
2852 let req = Request::builder()
2853 .method(Method::POST)
2854 .uri("/mcp")
2855 .header("content-type", "application/json")
2856 .body(Body::from(body))
2857 .unwrap();
2858 let resp = app.oneshot(req).await.unwrap();
2859 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2860 }
2861
2862 #[test]
2865 fn redact_with_salt_is_deterministic_per_salt() {
2866 let salt = b"unit-test-salt";
2867 let a = redact_with_salt(salt, "rm -rf /");
2868 let b = redact_with_salt(salt, "rm -rf /");
2869 assert_eq!(a, b, "same input + salt must yield identical hash");
2870 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
2871 assert!(
2872 a.chars().all(|c| c.is_ascii_hexdigit()),
2873 "redacted hash must be lowercase hex: {a}"
2874 );
2875 }
2876
2877 #[test]
2878 fn redact_with_salt_differs_across_salts() {
2879 let v = "the-same-value";
2880 let h1 = redact_with_salt(b"salt-one", v);
2881 let h2 = redact_with_salt(b"salt-two", v);
2882 assert_ne!(
2883 h1, h2,
2884 "different salts must produce different hashes for the same value"
2885 );
2886 }
2887
2888 #[test]
2889 fn redact_with_salt_distinguishes_values() {
2890 let salt = b"k";
2891 let h1 = redact_with_salt(salt, "alpha");
2892 let h2 = redact_with_salt(salt, "beta");
2893 assert_ne!(h1, h2, "different values must produce different hashes");
2895 }
2896
2897 #[test]
2898 fn policy_with_configured_salt_redacts_consistently() {
2899 let cfg = RbacConfig {
2900 enabled: true,
2901 roles: vec![],
2902 redaction_salt: Some(SecretString::from("my-stable-salt")),
2903 };
2904 let p1 = RbacPolicy::new(&cfg);
2905 let p2 = RbacPolicy::new(&cfg);
2906 assert_eq!(
2907 p1.redact_arg("payload"),
2908 p2.redact_arg("payload"),
2909 "policies built from the same configured salt must agree"
2910 );
2911 }
2912
2913 #[test]
2914 fn policy_without_configured_salt_uses_process_salt() {
2915 let cfg = RbacConfig {
2916 enabled: true,
2917 roles: vec![],
2918 redaction_salt: None,
2919 };
2920 let p1 = RbacPolicy::new(&cfg);
2921 let p2 = RbacPolicy::new(&cfg);
2922 assert_eq!(
2924 p1.redact_arg("payload"),
2925 p2.redact_arg("payload"),
2926 "process-wide salt must be consistent within one process"
2927 );
2928 }
2929
2930 #[tokio::test]
2942 async fn deny_path_uses_explicit_identity_not_task_local() {
2943 let policy = Arc::new(test_policy());
2944 let id = AuthIdentity {
2945 method: crate::auth::AuthMethod::BearerToken,
2946 name: "alice-the-auditor".into(),
2947 role: "viewer".into(),
2948 raw_token: None,
2949 sub: None,
2950 };
2951 let app = rbac_router_with_identity(policy, id);
2952 let body = tool_call_body("resource_delete", &serde_json::json!({}));
2954 let req = Request::builder()
2955 .method(Method::POST)
2956 .uri("/mcp")
2957 .header("content-type", "application/json")
2958 .body(Body::from(body))
2959 .unwrap();
2960 let resp = app.oneshot(req).await.unwrap();
2961 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2962 }
2963
2964 fn restricted_exec_identity() -> AuthIdentity {
2967 AuthIdentity {
2968 method: crate::auth::AuthMethod::BearerToken,
2969 name: "carol".into(),
2970 role: "restricted-exec".into(),
2971 raw_token: None,
2972 sub: None,
2973 }
2974 }
2975
2976 #[test]
2977 fn has_argument_allowlist_matches_configured_tool_argument() {
2978 let policy = test_policy();
2979 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2980 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2981 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2982 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2983 }
2984
2985 #[tokio::test]
2986 async fn array_arg_with_matching_allowlist_is_denied() {
2987 let policy = Arc::new(test_policy());
2988 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2989 let body = tool_call_body(
2990 "resource_exec",
2991 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2992 );
2993 let req = Request::builder()
2994 .method(Method::POST)
2995 .uri("/mcp")
2996 .header("content-type", "application/json")
2997 .body(Body::from(body))
2998 .unwrap();
2999 let resp = app.oneshot(req).await.unwrap();
3000 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3001 }
3002
3003 #[tokio::test]
3004 async fn object_arg_with_matching_allowlist_is_denied() {
3005 let policy = Arc::new(test_policy());
3006 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3007 let body = tool_call_body(
3008 "resource_exec",
3009 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
3010 );
3011 let req = Request::builder()
3012 .method(Method::POST)
3013 .uri("/mcp")
3014 .header("content-type", "application/json")
3015 .body(Body::from(body))
3016 .unwrap();
3017 let resp = app.oneshot(req).await.unwrap();
3018 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3019 }
3020
3021 #[tokio::test]
3022 async fn number_arg_with_matching_allowlist_is_denied() {
3023 let policy = Arc::new(test_policy());
3024 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3025 let body = tool_call_body(
3026 "resource_exec",
3027 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
3028 );
3029 let req = Request::builder()
3030 .method(Method::POST)
3031 .uri("/mcp")
3032 .header("content-type", "application/json")
3033 .body(Body::from(body))
3034 .unwrap();
3035 let resp = app.oneshot(req).await.unwrap();
3036 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3037 }
3038
3039 #[tokio::test]
3040 async fn bool_arg_with_matching_allowlist_is_denied() {
3041 let policy = Arc::new(test_policy());
3042 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3043 let body = tool_call_body(
3044 "resource_exec",
3045 &serde_json::json!({ "host": "dev-1", "cmd": true }),
3046 );
3047 let req = Request::builder()
3048 .method(Method::POST)
3049 .uri("/mcp")
3050 .header("content-type", "application/json")
3051 .body(Body::from(body))
3052 .unwrap();
3053 let resp = app.oneshot(req).await.unwrap();
3054 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3055 }
3056
3057 #[tokio::test]
3058 async fn null_arg_with_matching_allowlist_is_denied() {
3059 let policy = Arc::new(test_policy());
3060 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3061 let body = tool_call_body(
3062 "resource_exec",
3063 &serde_json::json!({ "host": "dev-1", "cmd": null }),
3064 );
3065 let req = Request::builder()
3066 .method(Method::POST)
3067 .uri("/mcp")
3068 .header("content-type", "application/json")
3069 .body(Body::from(body))
3070 .unwrap();
3071 let resp = app.oneshot(req).await.unwrap();
3072 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3073 }
3074
3075 #[tokio::test]
3076 async fn non_string_arg_without_allowlist_is_passthrough() {
3077 let policy = Arc::new(test_policy());
3081 let id = AuthIdentity {
3082 method: crate::auth::AuthMethod::BearerToken,
3083 name: "olivia".into(),
3084 role: "ops".into(),
3085 raw_token: None,
3086 sub: None,
3087 };
3088 let app = rbac_router_with_identity(policy, id);
3089 let body = tool_call_body(
3090 "resource_exec",
3091 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
3092 );
3093 let req = Request::builder()
3094 .method(Method::POST)
3095 .uri("/mcp")
3096 .header("content-type", "application/json")
3097 .body(Body::from(body))
3098 .unwrap();
3099 let resp = app.oneshot(req).await.unwrap();
3100 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3101 }
3102
3103 #[tokio::test]
3104 async fn string_arg_in_allowlist_still_passes() {
3105 let policy = Arc::new(test_policy());
3106 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3107 let body = tool_call_body(
3108 "resource_exec",
3109 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
3110 );
3111 let req = Request::builder()
3112 .method(Method::POST)
3113 .uri("/mcp")
3114 .header("content-type", "application/json")
3115 .body(Body::from(body))
3116 .unwrap();
3117 let resp = app.oneshot(req).await.unwrap();
3118 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3119 }
3120
3121 async fn exec_status(args: &serde_json::Value) -> StatusCode {
3130 let policy = Arc::new(test_policy());
3131 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3132 let body = tool_call_body("resource_exec", args);
3133 let req = Request::builder()
3134 .method(Method::POST)
3135 .uri("/mcp")
3136 .header("content-type", "application/json")
3137 .body(Body::from(body))
3138 .unwrap();
3139 app.oneshot(req).await.unwrap().status()
3140 }
3141
3142 #[tokio::test]
3143 async fn non_string_host_is_denied_for_every_json_type() {
3144 for host in [
3145 serde_json::json!(["prod-1"]),
3146 serde_json::json!({ "name": "prod-1" }),
3147 serde_json::json!(42),
3148 serde_json::json!(true),
3149 serde_json::json!(null),
3150 ] {
3151 let args = serde_json::json!({ "host": host, "cmd": "sh" });
3152 assert_eq!(
3153 exec_status(&args).await,
3154 StatusCode::FORBIDDEN,
3155 "non-string host must not bypass host globs: {host:?}"
3156 );
3157 }
3158 }
3159
3160 #[tokio::test]
3161 async fn string_host_outside_globs_still_denied() {
3162 let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
3163 assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
3164 }
3165
3166 #[tokio::test]
3167 async fn string_host_inside_globs_still_allowed() {
3168 let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
3169 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3170 }
3171
3172 #[tokio::test]
3176 async fn absent_host_still_routes_to_check_operation() {
3177 let args = serde_json::json!({ "cmd": "sh" });
3178 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3179 }
3180
3181 fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
3190 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
3191 .with_argument_allowlists(vec![
3192 ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
3193 ]);
3194 let mut config = RbacConfig::with_roles(vec![role]);
3195 config.enabled = true;
3196 RbacPolicy::new(&config)
3197 }
3198
3199 fn viewer_identity() -> AuthIdentity {
3200 AuthIdentity {
3201 method: crate::auth::AuthMethod::BearerToken,
3202 name: "viewer-1".into(),
3203 role: "viewer".into(),
3204 raw_token: None,
3205 sub: None,
3206 }
3207 }
3208
3209 async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
3210 let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
3211 let body = serde_json::json!({
3212 "jsonrpc": "2.0",
3213 "id": 1,
3214 "method": "tools/call",
3215 "params": params
3216 })
3217 .to_string();
3218 let req = Request::builder()
3219 .method(Method::POST)
3220 .uri("/mcp")
3221 .header("content-type", "application/json")
3222 .body(Body::from(body))
3223 .unwrap();
3224 app.oneshot(req).await.unwrap().status()
3225 }
3226
3227 #[tokio::test]
3228 async fn required_false_still_allows_omitting_the_argument() {
3229 let params = serde_json::json!({ "name": "run", "arguments": {} });
3230 assert_ne!(
3231 run_status(required_policy(vec!["ls".into()], false), ¶ms).await,
3232 StatusCode::FORBIDDEN,
3233 "default behaviour must be unchanged"
3234 );
3235 }
3236
3237 #[tokio::test]
3238 async fn required_true_denies_omitted_argument() {
3239 let params = serde_json::json!({ "name": "run", "arguments": {} });
3240 assert_eq!(
3241 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3242 StatusCode::FORBIDDEN
3243 );
3244 }
3245
3246 #[tokio::test]
3247 async fn required_true_allows_permitted_value() {
3248 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
3249 assert_ne!(
3250 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3251 StatusCode::FORBIDDEN
3252 );
3253 }
3254
3255 #[tokio::test]
3256 async fn required_true_still_denies_disallowed_value() {
3257 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
3258 assert_eq!(
3259 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3260 StatusCode::FORBIDDEN
3261 );
3262 }
3263
3264 #[tokio::test]
3265 async fn required_true_denies_non_string_value() {
3266 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
3267 assert_eq!(
3268 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3269 StatusCode::FORBIDDEN
3270 );
3271 }
3272
3273 #[tokio::test]
3274 async fn required_true_denies_absent_or_non_object_arguments() {
3275 for params in [
3276 serde_json::json!({ "name": "run" }),
3277 serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
3278 serde_json::json!({ "name": "run", "arguments": null }),
3279 ] {
3280 assert_eq!(
3281 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3282 StatusCode::FORBIDDEN,
3283 "omitting the arguments object must not skip `required`: {params:?}"
3284 );
3285 }
3286 }
3287
3288 #[tokio::test]
3291 async fn required_true_with_empty_allowed_accepts_any_string() {
3292 let params =
3293 serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
3294 assert_ne!(
3295 run_status(required_policy(vec![], true), ¶ms).await,
3296 StatusCode::FORBIDDEN
3297 );
3298 }
3299
3300 #[tokio::test]
3301 async fn required_true_with_empty_allowed_denies_omitted_argument() {
3302 let params = serde_json::json!({ "name": "run", "arguments": {} });
3303 assert_eq!(
3304 run_status(required_policy(vec![], true), ¶ms).await,
3305 StatusCode::FORBIDDEN
3306 );
3307 }
3308
3309 #[tokio::test]
3310 async fn required_true_with_empty_allowed_denies_non_string() {
3311 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
3312 assert_eq!(
3313 run_status(required_policy(vec![], true), ¶ms).await,
3314 StatusCode::FORBIDDEN
3315 );
3316 }
3317
3318 #[tokio::test]
3319 async fn required_honours_globbed_tool_patterns() {
3320 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
3321 .with_argument_allowlists(vec![
3322 ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
3323 ]);
3324 let mut config = RbacConfig::with_roles(vec![role]);
3325 config.enabled = true;
3326 let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
3327 assert_eq!(
3328 run_status(RbacPolicy::new(&config), ¶ms).await,
3329 StatusCode::FORBIDDEN,
3330 "a globbed tool pattern must enforce presence, not just value"
3331 );
3332 }
3333
3334 #[test]
3335 fn required_defaults_to_false_when_absent_from_toml() {
3336 let cfg: RbacConfig = toml::from_str(
3337 r#"
3338 enabled = true
3339 [[roles]]
3340 name = "viewer"
3341 allow = ["run"]
3342 [[roles.argument_allowlists]]
3343 tool = "run"
3344 argument = "cmd"
3345 allowed = ["ls"]
3346 "#,
3347 )
3348 .expect("config without `required` must still deserialize");
3349 assert!(
3350 !cfg.roles[0].argument_allowlists[0].required,
3351 "omitted `required` must default to false so existing configs are unchanged"
3352 );
3353 }
3354
3355 #[test]
3356 fn unknown_rbac_config_key_is_rejected() {
3357 let err = toml::from_str::<RbacConfig>(
3358 "
3359 enabled = true
3360 typo_roles = []
3361 ",
3362 )
3363 .unwrap_err();
3364
3365 let msg = err.to_string();
3366 assert!(
3367 msg.contains("typo_roles"),
3368 "error must name the offending key: {msg}"
3369 );
3370 }
3371}