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]
138pub fn current_role() -> Option<String> {
139 CURRENT_ROLE
140 .try_with(Clone::clone)
141 .ok()
142 .filter(|s| !s.is_empty())
143}
144
145#[must_use]
159pub fn current_identity() -> Option<String> {
160 CURRENT_IDENTITY
161 .try_with(Clone::clone)
162 .ok()
163 .filter(|s| !s.is_empty())
164}
165
166#[must_use]
180pub fn current_token() -> Option<SecretString> {
181 CURRENT_TOKEN
182 .try_with(|t| {
183 if t.expose_secret().is_empty() {
184 None
185 } else {
186 Some(t.clone())
187 }
188 })
189 .ok()
190 .flatten()
191}
192
193#[must_use]
197pub fn current_sub() -> Option<String> {
198 CURRENT_SUB
199 .try_with(Clone::clone)
200 .ok()
201 .filter(|s| !s.is_empty())
202}
203
204pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
211 CURRENT_TOKEN.scope(token, f).await
212}
213
214pub async fn with_rbac_scope<F: Future>(
221 role: String,
222 identity: String,
223 token: SecretString,
224 sub: String,
225 f: F,
226) -> F::Output {
227 with_rbac_scope_lazy(role, identity, token, sub, || f).await
228}
229
230pub(crate) async fn with_rbac_scope_lazy<T, F, Fut>(
231 role: String,
232 identity: String,
233 token: SecretString,
234 sub: String,
235 f: F,
236) -> T
237where
238 F: FnOnce() -> Fut,
239 Fut: Future<Output = T>,
240{
241 CURRENT_ROLE
242 .scope(role, async move {
243 CURRENT_IDENTITY
244 .scope(identity, async move {
245 CURRENT_TOKEN
246 .scope(token, async move {
247 CURRENT_SUB.scope(sub, async move { f().await }).await
248 })
249 .await
250 })
251 .await
252 })
253 .await
254}
255
256#[derive(Debug, Clone, Deserialize)]
258#[serde(deny_unknown_fields)]
259#[non_exhaustive]
260pub struct RoleConfig {
261 pub name: String,
263 #[serde(default)]
265 pub description: Option<String>,
266 #[serde(default)]
268 pub allow: Vec<String>,
269 #[serde(default)]
271 pub deny: Vec<String>,
272 #[serde(default = "default_hosts")]
274 pub hosts: Vec<String>,
275 #[serde(default)]
279 pub argument_allowlists: Vec<ArgumentAllowlist>,
280}
281
282impl RoleConfig {
283 #[must_use]
285 pub fn new(name: impl Into<String>, allow: Vec<String>, hosts: Vec<String>) -> Self {
286 Self {
287 name: name.into(),
288 description: None,
289 allow,
290 deny: vec![],
291 hosts,
292 argument_allowlists: vec![],
293 }
294 }
295
296 #[must_use]
298 pub fn with_deny(mut self, deny: Vec<String>) -> Self {
299 self.deny = deny;
300 self
301 }
302
303 #[must_use]
305 pub fn with_argument_allowlists(mut self, allowlists: Vec<ArgumentAllowlist>) -> Self {
306 self.argument_allowlists = allowlists;
307 self
308 }
309}
310
311#[derive(Debug, Clone, Deserialize)]
376#[serde(deny_unknown_fields)]
377#[non_exhaustive]
378pub struct ArgumentAllowlist {
379 pub tool: String,
381 pub argument: String,
383 #[serde(default)]
385 pub allowed: Vec<String>,
386 #[serde(default)]
399 pub required: bool,
400 #[serde(default)]
414 pub deny_unknown_arguments: bool,
415}
416
417impl ArgumentAllowlist {
418 #[must_use]
424 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
425 Self {
426 tool: tool.into(),
427 argument: argument.into(),
428 allowed,
429 required: false,
430 deny_unknown_arguments: false,
431 }
432 }
433
434 #[must_use]
439 pub fn new_required(
440 tool: impl Into<String>,
441 argument: impl Into<String>,
442 allowed: Vec<String>,
443 ) -> Self {
444 Self::new(tool, argument, allowed).with_required(true)
445 }
446
447 #[must_use]
449 pub const fn with_required(mut self, required: bool) -> Self {
450 self.required = required;
451 self
452 }
453
454 #[must_use]
459 pub const fn with_deny_unknown_arguments(mut self, deny: bool) -> Self {
460 self.deny_unknown_arguments = deny;
461 self
462 }
463}
464
465fn default_hosts() -> Vec<String> {
466 vec!["*".into()]
467}
468
469#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
478#[serde(rename_all = "kebab-case")]
479#[non_exhaustive]
480pub enum AllowOperationMatching {
481 #[default]
487 Legacy,
488 Glob,
493}
494
495#[derive(Debug, Clone, Default, Deserialize)]
497#[serde(deny_unknown_fields)]
498#[non_exhaustive]
499pub struct RbacConfig {
500 #[serde(default)]
502 pub enabled: bool,
503 #[serde(default)]
505 pub roles: Vec<RoleConfig>,
506 #[serde(default)]
510 pub allow_operation_matching: AllowOperationMatching,
511 #[serde(default)]
526 pub global_deny: Vec<String>,
527 #[serde(default)]
536 pub redaction_salt: Option<SecretString>,
537}
538
539impl RbacConfig {
540 #[must_use]
542 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
543 Self {
544 enabled: true,
545 roles,
546 allow_operation_matching: AllowOperationMatching::default(),
547 global_deny: Vec::new(),
548 redaction_salt: None,
549 }
550 }
551
552 #[must_use]
554 pub fn with_global_deny(mut self, global_deny: Vec<String>) -> Self {
555 self.global_deny = global_deny;
556 self
557 }
558
559 #[must_use]
561 pub fn with_allow_operation_matching(mut self, mode: AllowOperationMatching) -> Self {
562 self.allow_operation_matching = mode;
563 self
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569#[non_exhaustive]
570pub enum RbacDecision {
571 Allow,
573 Deny,
575}
576
577#[derive(Debug, Clone, serde::Serialize)]
579#[non_exhaustive]
580pub struct RbacRoleSummary {
581 pub name: String,
583 pub allow: usize,
585 pub deny: usize,
587 pub hosts: usize,
589 pub argument_allowlists: usize,
591}
592
593#[derive(Debug, Clone, serde::Serialize)]
595#[non_exhaustive]
596pub struct RbacPolicySummary {
597 pub enabled: bool,
599 pub global_deny: usize,
601 pub roles: Vec<RbacRoleSummary>,
603}
604
605#[derive(Debug, Clone)]
611#[non_exhaustive]
612pub struct RbacPolicy {
613 roles: Vec<RoleConfig>,
614 enabled: bool,
615 allow_operation_matching: AllowOperationMatching,
616 global_deny: Vec<String>,
617 redaction_salt: Arc<SecretString>,
620}
621
622impl RbacPolicy {
623 #[must_use]
626 pub fn new(config: &RbacConfig) -> Self {
627 warn_on_optional_value_allowlists(&config.roles);
628 warn_on_literal_allow_globs(&config.roles, config.allow_operation_matching);
629 warn_on_inert_global_deny(config);
630 let salt = config
631 .redaction_salt
632 .clone()
633 .unwrap_or_else(|| process_redaction_salt().clone());
634 Self {
635 roles: config.roles.clone(),
636 enabled: config.enabled,
637 allow_operation_matching: config.allow_operation_matching,
638 global_deny: config.global_deny.clone(),
639 redaction_salt: Arc::new(salt),
640 }
641 }
642
643 #[must_use]
645 pub fn disabled() -> Self {
646 Self {
647 roles: Vec::new(),
648 enabled: false,
649 allow_operation_matching: AllowOperationMatching::default(),
650 global_deny: Vec::new(),
651 redaction_salt: Arc::new(process_redaction_salt().clone()),
652 }
653 }
654
655 #[must_use]
657 pub fn is_enabled(&self) -> bool {
658 self.enabled
659 }
660
661 #[must_use]
666 pub fn summary(&self) -> RbacPolicySummary {
667 let roles = self
668 .roles
669 .iter()
670 .map(|r| RbacRoleSummary {
671 name: r.name.clone(),
672 allow: r.allow.len(),
673 deny: r.deny.len(),
674 hosts: r.hosts.len(),
675 argument_allowlists: r.argument_allowlists.len(),
676 })
677 .collect();
678 RbacPolicySummary {
679 enabled: self.enabled,
680 global_deny: self.global_deny.len(),
681 roles,
682 }
683 }
684
685 fn global_denied(&self, operation: &str) -> bool {
690 self.global_deny.iter().any(|d| glob_match(d, operation))
691 }
692
693 fn role_denies(role_cfg: &RoleConfig, operation: &str) -> bool {
700 role_cfg.deny.iter().any(|d| glob_match(d, operation))
701 }
702
703 fn role_allows(&self, role_cfg: &RoleConfig, operation: &str) -> bool {
705 role_cfg.allow.iter().any(|a| {
706 a == "*"
707 || match self.allow_operation_matching {
708 AllowOperationMatching::Legacy => a == operation,
709 AllowOperationMatching::Glob => glob_match(a, operation),
710 }
711 })
712 }
713
714 #[must_use]
719 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
720 if !self.enabled {
721 return RbacDecision::Allow;
722 }
723 if self.global_denied(operation) {
724 return RbacDecision::Deny;
725 }
726 let Some(role_cfg) = self.find_role(role) else {
727 return RbacDecision::Deny;
728 };
729 if Self::role_denies(role_cfg, operation) {
730 return RbacDecision::Deny;
731 }
732 if self.role_allows(role_cfg, operation) {
733 return RbacDecision::Allow;
734 }
735 RbacDecision::Deny
736 }
737
738 #[must_use]
748 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
749 if !self.enabled {
750 return RbacDecision::Allow;
751 }
752 if self.global_denied(operation) {
753 return RbacDecision::Deny;
754 }
755 let Some(role_cfg) = self.find_role(role) else {
756 return RbacDecision::Deny;
757 };
758 if Self::role_denies(role_cfg, operation) {
759 return RbacDecision::Deny;
760 }
761 if !self.role_allows(role_cfg, operation) {
762 return RbacDecision::Deny;
763 }
764 if !Self::host_matches(&role_cfg.hosts, host) {
765 return RbacDecision::Deny;
766 }
767 RbacDecision::Allow
768 }
769
770 #[must_use]
774 pub fn host_visible(&self, role: &str, host: &str) -> bool {
775 if !self.enabled {
776 return true;
777 }
778 let Some(role_cfg) = self.find_role(role) else {
779 return false;
780 };
781 Self::host_matches(&role_cfg.hosts, host)
782 }
783
784 #[must_use]
786 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
787 self.find_role(role).map(|r| r.hosts.as_slice())
788 }
789
790 #[must_use]
829 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
830 if !self.enabled {
831 return true;
832 }
833 let Some(role_cfg) = self.find_role(role) else {
834 return false;
835 };
836 for al in &role_cfg.argument_allowlists {
837 if al.tool != tool && !glob_match(&al.tool, tool) {
838 continue;
839 }
840 if al.argument != argument {
841 continue;
842 }
843 if al.allowed.is_empty() {
844 continue;
845 }
846 let Some(tokens) = shlex::split(value) else {
851 return false;
852 };
853 let Some(first_token) = tokens.first() else {
854 return false;
855 };
856 if first_token.is_empty() {
860 return false;
861 }
862 let basename = first_token
866 .rsplit('/')
867 .next()
868 .unwrap_or(first_token.as_str());
869 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
870 return false;
871 }
872 }
873 true
874 }
875
876 #[must_use]
886 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
887 if !self.enabled {
888 return false;
889 }
890 let Some(role_cfg) = self.find_role(role) else {
891 return false;
892 };
893 role_cfg.argument_allowlists.iter().any(|al| {
894 (al.tool == tool || glob_match(&al.tool, tool))
895 && al.argument == argument
896 && !al.allowed.is_empty()
897 })
898 }
899
900 fn strict_argument_names(&self, role: &str, tool: &str) -> Option<Vec<&str>> {
910 if !self.enabled {
911 return None;
912 }
913 let role_cfg = self.find_role(role)?;
914 let matching = || {
915 role_cfg
916 .argument_allowlists
917 .iter()
918 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
919 };
920 if !matching().any(|al| al.deny_unknown_arguments) {
921 return None;
922 }
923 Some(matching().map(|al| al.argument.as_str()).collect())
924 }
925
926 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
928 self.roles.iter().find(|r| r.name == name)
929 }
930
931 fn missing_required_argument(
942 &self,
943 role: &str,
944 tool: &str,
945 args: Option<&serde_json::Map<String, serde_json::Value>>,
946 ) -> Option<&str> {
947 if !self.enabled {
948 return None;
949 }
950 let role_cfg = self.find_role(role)?;
951 role_cfg
952 .argument_allowlists
953 .iter()
954 .filter(|al| al.required)
955 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
959 .find(|al| {
960 !args.is_some_and(|a| {
961 a.get(&al.argument)
962 .is_some_and(serde_json::Value::is_string)
963 })
964 })
965 .map(|al| al.argument.as_str())
966 }
967
968 fn host_matches(patterns: &[String], host: &str) -> bool {
988 let host_lower = patterns
992 .iter()
993 .any(|p| p.contains('*'))
994 .then(|| host.to_ascii_lowercase());
995 patterns.iter().any(|p| {
996 if p.contains('*') {
997 host_lower
998 .as_deref()
999 .is_some_and(|h| glob_match(&p.to_ascii_lowercase(), h))
1000 } else {
1001 p.eq_ignore_ascii_case(host)
1002 }
1003 })
1004 }
1005
1006 #[must_use]
1015 pub fn redact_arg(&self, value: &str) -> String {
1016 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
1017 }
1018}
1019
1020fn warn_on_literal_allow_globs(roles: &[RoleConfig], mode: AllowOperationMatching) {
1028 match mode {
1029 AllowOperationMatching::Glob => return,
1030 AllowOperationMatching::Legacy => {}
1031 }
1032 for role in roles {
1033 for entry in role.allow.iter().filter(|a| *a != "*" && a.contains('*')) {
1034 tracing::warn!(
1035 role = %role.name,
1036 operation = %entry,
1037 "allow entry contains '*' but operation matching is 'legacy'; \
1038 the '*' is matched literally, not as a pattern -- set \
1039 rbac.allow_operation_matching = \"glob\" to enable globbing, \
1040 or list the operation names exactly"
1041 );
1042 }
1043 }
1044}
1045
1046fn warn_on_inert_global_deny(config: &RbacConfig) {
1048 if !config.enabled && !config.global_deny.is_empty() {
1049 tracing::warn!(
1050 patterns = config.global_deny.len(),
1051 "rbac.global_deny is configured but rbac.enabled is false; \
1052 the kill switch is inert because all checks short-circuit to allow"
1053 );
1054 }
1055}
1056
1057fn warn_on_optional_value_allowlists(roles: &[RoleConfig]) {
1058 for role in roles {
1059 for allowlist in &role.argument_allowlists {
1060 if !allowlist.allowed.is_empty() && !allowlist.required {
1061 tracing::warn!(
1062 role = %role.name,
1063 tool = %allowlist.tool,
1064 argument = %allowlist.argument,
1065 "argument allowlist is optional and fails open when the \
1066 argument is omitted: the allowed-value list is enforced \
1067 only if the caller supplies the argument, so a tool that \
1068 substitutes its own default bypasses it entirely -- set \
1069 `required = true` in TOML, or construct via \
1070 `ArgumentAllowlist::new_required`, to reject calls that \
1071 omit it"
1072 );
1073 }
1074 }
1075 }
1076}
1077
1078fn process_redaction_salt() -> &'static SecretString {
1081 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
1082 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
1083 PROCESS_SALT.get_or_init(|| {
1084 let mut bytes = [0u8; 32];
1085 rand::fill(&mut bytes);
1086 SecretString::from(STANDARD_NO_PAD.encode(bytes))
1089 })
1090}
1091
1092fn redact_with_salt(salt: &[u8], value: &str) -> String {
1097 use std::fmt::Write as _;
1098
1099 use sha2::Digest as _;
1100
1101 type HmacSha256 = Hmac<Sha256>;
1102 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
1108 m
1109 } else {
1110 let digest = Sha256::digest(salt);
1111 #[allow(
1112 clippy::expect_used,
1113 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
1114 )]
1115 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
1116 };
1117 mac.update(value.as_bytes());
1118 let bytes = mac.finalize().into_bytes();
1119 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
1121 let mut out = String::with_capacity(8);
1122 for b in prefix {
1123 let _ = write!(out, "{b:02x}");
1124 }
1125 out
1126}
1127
1128#[allow(
1149 clippy::too_many_lines,
1150 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
1151)]
1152pub(crate) async fn rbac_middleware(
1156 policy: Arc<RbacPolicy>,
1157 tool_limiter: Option<Arc<ToolRateLimiter>>,
1158 req: Request<Body>,
1159 next: Next,
1160) -> Response {
1161 if req.method() != Method::POST {
1163 return next.run(req).await;
1164 }
1165
1166 let peer_key = tool_limiter
1172 .is_some()
1173 .then(|| crate::transport::limiter_client_key(req.extensions()));
1174
1175 let identity = req.extensions().get::<AuthIdentity>();
1177 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
1178 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
1179 let raw_token: SecretString = identity
1182 .and_then(|id| id.raw_token.clone())
1183 .unwrap_or_else(|| SecretString::from(String::new()));
1184 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
1185
1186 if policy.is_enabled() && identity.is_none() {
1188 return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
1189 }
1190
1191 let (parts, body) = req.into_parts();
1193 let bytes = match body.collect().await {
1194 Ok(collected) => collected.to_bytes(),
1195 Err(e) => {
1196 tracing::error!(error = %e, "failed to read request body");
1197 return (
1198 StatusCode::INTERNAL_SERVER_ERROR,
1199 "failed to read request body",
1200 )
1201 .into_response();
1202 }
1203 };
1204
1205 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
1207 let tool_calls = extract_tool_calls(&json);
1208 if !tool_calls.is_empty() {
1209 for params in tool_calls {
1210 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_key.as_ref()) {
1211 #[cfg(feature = "metrics")]
1212 crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
1213 return resp;
1214 }
1215 if policy.is_enabled()
1216 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
1217 {
1218 return resp;
1219 }
1220 }
1221 }
1222 }
1223 let req = Request::from_parts(parts, Body::from(bytes));
1227
1228 if role.is_empty() {
1230 next.run(req).await
1231 } else {
1232 CURRENT_ROLE
1233 .scope(
1234 role,
1235 CURRENT_IDENTITY.scope(
1236 identity_name,
1237 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
1238 ),
1239 )
1240 .await
1241 }
1242}
1243
1244fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
1250 match value {
1251 serde_json::Value::Object(map) => map
1252 .get("method")
1253 .and_then(serde_json::Value::as_str)
1254 .filter(|method| *method == "tools/call")
1255 .and_then(|_| map.get("params"))
1256 .into_iter()
1257 .collect(),
1258 serde_json::Value::Array(items) => items
1259 .iter()
1260 .filter_map(|item| match item {
1261 serde_json::Value::Object(map) => map
1262 .get("method")
1263 .and_then(serde_json::Value::as_str)
1264 .filter(|method| *method == "tools/call")
1265 .and_then(|_| map.get("params")),
1266 serde_json::Value::Null
1267 | serde_json::Value::Bool(_)
1268 | serde_json::Value::Number(_)
1269 | serde_json::Value::String(_)
1270 | serde_json::Value::Array(_) => None,
1271 })
1272 .collect(),
1273 serde_json::Value::Null
1274 | serde_json::Value::Bool(_)
1275 | serde_json::Value::Number(_)
1276 | serde_json::Value::String(_) => Vec::new(),
1277 }
1278}
1279
1280fn enforce_rate_limit(
1283 tool_limiter: Option<&ToolRateLimiter>,
1284 peer_key: Option<&crate::transport::RateLimitKey>,
1285) -> Option<Response> {
1286 let limiter = tool_limiter?;
1287 let key = peer_key?;
1288 match limiter.check_key_detailed(key) {
1289 Ok(()) => None,
1290 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1291 tracing::warn!(rate_limit_key = %key, "tool invocation rate limited");
1292 Some(
1293 RmcpServerKitError::RateLimitedFor {
1294 message: "too many tool invocations".into(),
1295 retry_after: wait,
1296 }
1297 .into_response(),
1298 )
1299 }
1300 Err(BoundedLimiterDeny::CapacityFull) => {
1301 tracing::warn!(
1302 rate_limit_key = %key,
1303 "tool invocation limiter rejected unseen key because tracked-key capacity is full"
1304 );
1305 Some(
1306 (
1307 StatusCode::SERVICE_UNAVAILABLE,
1308 "rate limiter capacity exhausted",
1309 )
1310 .into_response(),
1311 )
1312 }
1313 }
1314}
1315
1316fn enforce_tool_policy(
1325 policy: &RbacPolicy,
1326 identity_name: &str,
1327 role: &str,
1328 params: &serde_json::Value,
1329) -> Option<Response> {
1330 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
1331 let host_value = params.get("arguments").and_then(|a| a.get("host"));
1332
1333 if let Some(value) = host_value
1341 && !value.is_string()
1342 {
1343 tracing::warn!(
1344 user = %identity_name,
1345 role = %role,
1346 tool = tool_name,
1347 value_type = json_value_type(value),
1348 "non-string host argument rejected"
1349 );
1350 return Some(
1351 RmcpServerKitError::Rbac(format!(
1352 "argument 'host' must be a string for tool '{tool_name}'"
1353 ))
1354 .into_response(),
1355 );
1356 }
1357 let host = host_value.and_then(|h| h.as_str());
1360
1361 let decision = if let Some(host) = host {
1362 policy.check(role, tool_name, host)
1363 } else {
1364 policy.check_operation(role, tool_name)
1365 };
1366 if decision == RbacDecision::Deny {
1367 tracing::warn!(
1368 user = %identity_name,
1369 role = %role,
1370 tool = tool_name,
1371 host = host.unwrap_or("-"),
1372 "RBAC denied"
1373 );
1374 return Some(
1375 RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
1376 .into_response(),
1377 );
1378 }
1379
1380 let args = params.get("arguments").and_then(|a| a.as_object());
1381 let strict = policy.strict_argument_names(role, tool_name);
1382 if let Some(args) = args {
1383 for (arg_key, arg_val) in args {
1384 if let Some(ref permitted) = strict
1385 && let Some(resp) = check_strict_argument(
1386 identity_name,
1387 role,
1388 tool_name,
1389 permitted,
1390 arg_key,
1391 arg_val,
1392 )
1393 {
1394 return Some(resp);
1395 }
1396 if let Some(resp) =
1397 check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
1398 {
1399 return Some(resp);
1400 }
1401 }
1402 }
1403 check_required_arguments(policy, identity_name, role, tool_name, args)
1404}
1405
1406fn check_strict_argument(
1411 identity_name: &str,
1412 role: &str,
1413 tool_name: &str,
1414 permitted: &[&str],
1415 arg_key: &str,
1416 arg_val: &serde_json::Value,
1417) -> Option<Response> {
1418 if !permitted.contains(&arg_key) {
1419 tracing::warn!(
1420 user = %identity_name,
1421 role = %role,
1422 tool = tool_name,
1423 argument = arg_key,
1424 "unknown argument rejected by strict allowlist"
1425 );
1426 return Some(
1427 RmcpServerKitError::Rbac(format!(
1428 "argument '{arg_key}' is not permitted for tool '{tool_name}'"
1429 ))
1430 .into_response(),
1431 );
1432 }
1433 if arg_val.is_object() || arg_val.is_array() {
1434 tracing::warn!(
1435 user = %identity_name,
1436 role = %role,
1437 tool = tool_name,
1438 argument = arg_key,
1439 value_type = json_value_type(arg_val),
1440 "structured argument rejected by strict allowlist"
1441 );
1442 return Some(
1443 RmcpServerKitError::Rbac(format!(
1444 "argument '{arg_key}' must not be an object or array for tool '{tool_name}'"
1445 ))
1446 .into_response(),
1447 );
1448 }
1449 None
1450}
1451
1452fn check_required_arguments(
1460 policy: &RbacPolicy,
1461 identity_name: &str,
1462 role: &str,
1463 tool_name: &str,
1464 args: Option<&serde_json::Map<String, serde_json::Value>>,
1465) -> Option<Response> {
1466 let missing = policy.missing_required_argument(role, tool_name, args)?;
1467 tracing::warn!(
1468 user = %identity_name,
1469 role = %role,
1470 tool = tool_name,
1471 argument = missing,
1472 "required argument missing"
1473 );
1474 Some(
1475 RmcpServerKitError::Rbac(format!(
1476 "argument '{missing}' is required for tool '{tool_name}'"
1477 ))
1478 .into_response(),
1479 )
1480}
1481
1482fn check_argument(
1483 policy: &RbacPolicy,
1484 identity_name: &str,
1485 role: &str,
1486 tool_name: &str,
1487 arg_key: &str,
1488 arg_val: &serde_json::Value,
1489) -> Option<Response> {
1490 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1491 return None;
1492 }
1493 let Some(val_str) = arg_val.as_str() else {
1494 tracing::warn!(
1500 user = %identity_name,
1501 role = %role,
1502 tool = tool_name,
1503 argument = arg_key,
1504 value_type = json_value_type(arg_val),
1505 "non-string argument rejected by allowlist"
1506 );
1507 return Some(
1508 RmcpServerKitError::Rbac(format!(
1509 "argument '{arg_key}' must be a string for tool '{tool_name}'"
1510 ))
1511 .into_response(),
1512 );
1513 };
1514 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1515 return None;
1516 }
1517 tracing::warn!(
1522 user = %identity_name,
1523 role = %role,
1524 tool = tool_name,
1525 argument = arg_key,
1526 arg_hmac = %policy.redact_arg(val_str),
1527 "argument not in allowlist"
1528 );
1529 Some(
1530 RmcpServerKitError::Rbac(format!(
1531 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1532 ))
1533 .into_response(),
1534 )
1535}
1536
1537fn json_value_type(v: &serde_json::Value) -> &'static str {
1538 match v {
1539 serde_json::Value::Null => "null",
1540 serde_json::Value::Bool(_) => "bool",
1541 serde_json::Value::Number(_) => "number",
1542 serde_json::Value::String(_) => "string",
1543 serde_json::Value::Array(_) => "array",
1544 serde_json::Value::Object(_) => "object",
1545 }
1546}
1547
1548fn glob_match(pattern: &str, text: &str) -> bool {
1558 let parts: Vec<&str> = pattern.split('*').collect();
1559 if parts.len() == 1 {
1560 return pattern == text;
1562 }
1563
1564 let pos = if let Some(&first) = parts.first()
1566 && !first.is_empty()
1567 {
1568 if !text.starts_with(first) {
1569 return false;
1570 }
1571 first.len()
1572 } else {
1573 0
1574 };
1575
1576 if let Some(&last) = parts.last()
1578 && !last.is_empty()
1579 {
1580 if !text.get(pos..).unwrap_or_default().ends_with(last) {
1581 return false;
1582 }
1583 let end = text.len() - last.len();
1585 if pos > end {
1586 return false;
1587 }
1588 let middle = text.get(pos..end).unwrap_or_default();
1590 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1591 return match_middle(middle, middle_parts);
1592 }
1593
1594 let middle = text.get(pos..).unwrap_or_default();
1596 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1597 match_middle(middle, middle_parts)
1598}
1599
1600fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1602 for part in parts {
1603 if part.is_empty() {
1604 continue;
1605 }
1606 if let Some(idx) = text.find(part) {
1607 text = text.get(idx + part.len()..).unwrap_or_default();
1608 } else {
1609 return false;
1610 }
1611 }
1612 true
1613}
1614
1615impl RbacConfig {
1616 pub fn apply_env_overrides(
1649 &mut self,
1650 ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1651 let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1652 let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1653 match (direct, file) {
1654 (None, None) => Ok(Vec::new()),
1655 (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1656 "{} and {} must not both be set",
1657 crate::config::RBAC_REDACTION_SALT_ENV,
1658 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1659 ))),
1660 (Some(value), None) => {
1661 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1662 self.redaction_salt = Some(SecretString::from(value));
1663 Ok(vec![crate::config::secret_env_report(
1664 crate::config::RBAC_REDACTION_SALT_ENV,
1665 "rbac.redaction_salt",
1666 crate::config::EnvOverrideSource::Env,
1667 )])
1668 }
1669 (None, Some(path)) => {
1670 let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1671 RmcpServerKitError::Config(format!(
1672 "failed to read {} file {path:?}: {error}",
1673 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1674 ))
1675 })?;
1676 let secret = crate::config::normalize_text_secret_file(secret);
1677 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1678 self.redaction_salt = Some(SecretString::from(secret));
1679 Ok(vec![crate::config::secret_env_report(
1680 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1681 "rbac.redaction_salt",
1682 crate::config::EnvOverrideSource::File,
1683 )])
1684 }
1685 }
1686 }
1687}
1688
1689fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1690 if value.trim().is_empty() {
1691 return Err(RmcpServerKitError::Config(format!(
1692 "{env_var} must not be empty or whitespace-only"
1693 )));
1694 }
1695 Ok(())
1696}
1697
1698#[cfg(test)]
1699mod tests {
1700 use std::net::IpAddr;
1701
1702 use super::*;
1703 use crate::transport::RateLimitKey;
1704
1705 fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1706 temp_env::with_vars(
1707 [
1708 (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1709 (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1710 ]
1711 .into_iter()
1712 .chain(vars.iter().copied())
1713 .collect::<Vec<_>>(),
1714 f,
1715 )
1716 }
1717
1718 #[test]
1719 fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1720 with_rbac_env(
1721 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1722 || {
1723 let mut cfg = RbacConfig::default();
1724 let report = cfg.apply_env_overrides().unwrap();
1725 assert!(cfg.redaction_salt.is_some());
1726 assert_eq!(report.len(), 1);
1727 assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1728 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1729 assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1730 assert!(report[0].value.is_none());
1731 assert!(!format!("{report:?}").contains("s3cret"));
1732 },
1733 );
1734 }
1735
1736 #[test]
1737 fn e7_redaction_salt_value_and_file_conflict_fails() {
1738 with_rbac_env(
1739 &[
1740 (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1741 (
1742 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1743 Some("/tmp/secret-file"),
1744 ),
1745 ],
1746 || {
1747 let mut cfg = RbacConfig::default();
1748 let err = cfg.apply_env_overrides().unwrap_err();
1749 let msg = err.to_string();
1750 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1751 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1752 },
1753 );
1754 }
1755
1756 #[test]
1757 fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1758 let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1759 let direct_redaction = redaction_from_direct_salt("same-salt");
1760
1761 assert_eq!(file_redaction, direct_redaction);
1762 assert_eq!(report.len(), 1);
1763 assert_eq!(
1764 report[0].env_var,
1765 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1766 );
1767 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1768 assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1769 assert!(report[0].value.is_none());
1770 }
1771
1772 #[test]
1773 fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1774 let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1775 assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1776
1777 let (spaced_redaction, _) = redaction_from_file(" same-salt \n").expect("spaced salt");
1778 assert_eq!(
1779 spaced_redaction,
1780 redaction_from_direct_salt(" same-salt ")
1781 );
1782 assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1783 }
1784
1785 #[derive(Clone, Default)]
1786 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
1787
1788 impl CapturedLogs {
1789 fn contents(&self) -> String {
1790 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
1791 String::from_utf8(bytes).unwrap_or_default()
1792 }
1793 }
1794
1795 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
1796
1797 impl std::io::Write for CapturedLogsWriter {
1798 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1799 if let Ok(mut guard) = self.0.lock() {
1800 guard.extend_from_slice(buf);
1801 }
1802 Ok(buf.len())
1803 }
1804
1805 fn flush(&mut self) -> std::io::Result<()> {
1806 Ok(())
1807 }
1808 }
1809
1810 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
1811 type Writer = CapturedLogsWriter;
1812
1813 fn make_writer(&'a self) -> Self::Writer {
1814 CapturedLogsWriter(Arc::clone(&self.0))
1815 }
1816 }
1817
1818 fn allowlist_warning_policy(allowlist: ArgumentAllowlist) -> RbacConfig {
1819 RbacConfig::with_roles(vec![
1820 RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1821 .with_argument_allowlists(vec![allowlist]),
1822 ])
1823 }
1824
1825 fn capture_policy_construction_logs(config: &RbacConfig) -> String {
1826 let logs = CapturedLogs::default();
1827 let subscriber = tracing_subscriber::fmt()
1828 .with_writer(logs.clone())
1829 .with_ansi(false)
1830 .without_time()
1831 .finish();
1832 let _guard = tracing::subscriber::set_default(subscriber);
1833
1834 let _policy = RbacPolicy::new(config);
1835 logs.contents()
1836 }
1837
1838 #[test]
1839 fn optional_non_empty_argument_allowlist_warns_once_at_policy_construction() {
1840 let config =
1841 allowlist_warning_policy(ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]));
1842
1843 let logs = capture_policy_construction_logs(&config);
1844
1845 assert_eq!(
1846 logs.matches("argument allowlist is optional and fails open")
1847 .count(),
1848 1,
1849 "exactly one warning expected for one optional non-empty allowlist: {logs}"
1850 );
1851 assert!(logs.contains("run"), "warning must name the tool: {logs}");
1852 assert!(
1853 logs.contains("cmd"),
1854 "warning must name the argument: {logs}"
1855 );
1856 assert!(
1857 logs.contains("required = true") && logs.contains("new_required"),
1858 "warning must name the remedy so an operator can act on it: {logs}"
1859 );
1860 }
1861
1862 #[test]
1863 fn required_argument_allowlist_does_not_warn_at_policy_construction() {
1864 let config = allowlist_warning_policy(
1865 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]).with_required(true),
1866 );
1867
1868 let logs = capture_policy_construction_logs(&config);
1869
1870 assert!(
1871 !logs.contains("argument allowlist is optional and fails open"),
1872 "required allowlist must not warn: {logs}"
1873 );
1874 }
1875
1876 #[test]
1877 fn new_required_sets_required_and_preserves_value_allowlist_behavior() {
1878 let optional = ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]);
1879 let required = ArgumentAllowlist::new_required("run", "cmd", vec!["ls".into()]);
1880
1881 assert_eq!(required.tool, optional.tool);
1882 assert_eq!(required.argument, optional.argument);
1883 assert_eq!(required.allowed, optional.allowed);
1884 assert!(required.required);
1885 assert!(!optional.required);
1886
1887 let optional_policy = RbacPolicy::new(&allowlist_warning_policy(optional));
1888 let required_policy = RbacPolicy::new(&allowlist_warning_policy(required));
1889 assert_eq!(
1890 optional_policy.argument_allowed("viewer", "run", "cmd", "ls -la"),
1891 required_policy.argument_allowed("viewer", "run", "cmd", "ls -la")
1892 );
1893 assert_eq!(
1894 optional_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /"),
1895 required_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /")
1896 );
1897 }
1898
1899 #[test]
1900 fn blank_redaction_salt_env_values_fail_closed() {
1901 for value in ["", "\n", " "] {
1902 with_rbac_env(
1903 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1904 || {
1905 let mut cfg = RbacConfig::default();
1906 let err = cfg.apply_env_overrides().unwrap_err();
1907 assert!(
1908 err.to_string()
1909 .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1910 );
1911 },
1912 );
1913 }
1914 }
1915
1916 #[test]
1917 fn blank_redaction_salt_file_values_fail_closed() {
1918 for value in ["", "\n", "\r\n", " \n"] {
1919 let err = redaction_from_file(value).unwrap_err();
1920 assert!(
1921 err.to_string()
1922 .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1923 );
1924 }
1925 }
1926
1927 fn redaction_from_direct_salt(salt: &str) -> String {
1928 RbacPolicy::new(&RbacConfig {
1929 redaction_salt: Some(SecretString::from(salt.to_owned())),
1930 ..RbacConfig::default()
1931 })
1932 .redact_arg("same-argument")
1933 }
1934
1935 fn redaction_from_file(
1936 content: &str,
1937 ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1938 let path = std::env::temp_dir().join(format!(
1939 "rmcp-server-kit-redaction-salt-{}.txt",
1940 std::time::SystemTime::now()
1941 .duration_since(std::time::UNIX_EPOCH)
1942 .expect("clock after epoch")
1943 .as_nanos()
1944 ));
1945 std::fs::write(&path, content).expect("write salt file");
1946 let path_string = path.to_string_lossy().to_string();
1947 let result = with_rbac_env(
1948 &[(
1949 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1950 Some(path_string.as_str()),
1951 )],
1952 || {
1953 let mut cfg = RbacConfig::default();
1954 let report = cfg.apply_env_overrides()?;
1955 let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1956 Ok((redaction, report))
1957 },
1958 );
1959 std::fs::remove_file(path).expect("remove salt file");
1960 result
1961 }
1962
1963 #[test]
1968 fn tool_limiter_burst_allows_initial_spike() {
1969 let limiter = build_tool_rate_limiter_with_policy(2, Some(4), KeyEvictionPolicy::default());
1970 let ip = RateLimitKey::Ip("10.9.9.9".parse::<IpAddr>().unwrap());
1971 for i in 0..4 {
1972 assert!(
1973 limiter.check_key(&ip).is_ok(),
1974 "burst request {i} should pass"
1975 );
1976 }
1977 assert!(
1978 limiter.check_key(&ip).is_err(),
1979 "request 5 must exceed the burst bucket"
1980 );
1981 }
1982
1983 #[test]
1985 fn tool_limiter_deny_sets_retry_after() {
1986 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
1987 let ip = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1988 assert!(enforce_rate_limit(Some(&limiter), Some(&ip)).is_none());
1989 let resp = enforce_rate_limit(Some(&limiter), Some(&ip))
1990 .expect("second call within the window must deny");
1991 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1992 let retry_after = resp
1993 .headers()
1994 .get(axum::http::header::RETRY_AFTER)
1995 .expect("Retry-After present")
1996 .to_str()
1997 .unwrap()
1998 .parse::<u64>()
1999 .unwrap();
2000 assert!(retry_after >= 1, "delta-seconds must be >= 1");
2001 }
2002
2003 #[test]
2004 fn tool_limiter_capacity_full_returns_503_without_retry_after() {
2005 let limiter = build_tool_rate_limiter_with_bounds(
2006 10,
2007 None,
2008 1,
2009 Duration::from_hours(1),
2010 KeyEvictionPolicy::RejectNew,
2011 );
2012 let established = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
2013 let unseen = RateLimitKey::Ip("10.8.8.9".parse::<IpAddr>().unwrap());
2014 assert!(enforce_rate_limit(Some(&limiter), Some(&established)).is_none());
2015
2016 let resp = enforce_rate_limit(Some(&limiter), Some(&unseen))
2017 .expect("unseen key must be rejected at capacity");
2018
2019 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
2020 assert!(
2021 resp.headers()
2022 .get(axum::http::header::RETRY_AFTER)
2023 .is_none()
2024 );
2025 }
2026
2027 fn test_policy() -> RbacPolicy {
2028 RbacPolicy::new(&RbacConfig {
2029 enabled: true,
2030 roles: vec![
2031 RoleConfig {
2032 name: "viewer".into(),
2033 description: Some("Read-only".into()),
2034 allow: vec![
2035 "list_hosts".into(),
2036 "resource_list".into(),
2037 "resource_inspect".into(),
2038 "resource_logs".into(),
2039 "system_info".into(),
2040 ],
2041 deny: vec![],
2042 hosts: vec!["*".into()],
2043 argument_allowlists: vec![],
2044 },
2045 RoleConfig {
2046 name: "deploy".into(),
2047 description: Some("Lifecycle management".into()),
2048 allow: vec![
2049 "list_hosts".into(),
2050 "resource_list".into(),
2051 "resource_run".into(),
2052 "resource_start".into(),
2053 "resource_stop".into(),
2054 "resource_restart".into(),
2055 "resource_logs".into(),
2056 "image_pull".into(),
2057 ],
2058 deny: vec!["resource_delete".into(), "resource_exec".into()],
2059 hosts: vec!["web-*".into(), "api-*".into()],
2060 argument_allowlists: vec![],
2061 },
2062 RoleConfig {
2063 name: "ops".into(),
2064 description: Some("Full access".into()),
2065 allow: vec!["*".into()],
2066 deny: vec![],
2067 hosts: vec!["*".into()],
2068 argument_allowlists: vec![],
2069 },
2070 RoleConfig {
2071 name: "restricted-exec".into(),
2072 description: Some("Exec with argument allowlist".into()),
2073 allow: vec!["resource_exec".into()],
2074 deny: vec![],
2075 hosts: vec!["dev-*".into()],
2076 argument_allowlists: vec![ArgumentAllowlist {
2077 tool: "resource_exec".into(),
2078 argument: "cmd".into(),
2079 allowed: vec![
2080 "sh".into(),
2081 "bash".into(),
2082 "cat".into(),
2083 "ls".into(),
2084 "ps".into(),
2085 ],
2086 required: false,
2087 deny_unknown_arguments: false,
2088 }],
2089 },
2090 ],
2091 redaction_salt: None,
2092 ..RbacConfig::default()
2093 })
2094 }
2095
2096 #[test]
2099 fn glob_exact_match() {
2100 assert!(glob_match("web-prod-1", "web-prod-1"));
2101 assert!(!glob_match("web-prod-1", "web-prod-2"));
2102 }
2103
2104 #[test]
2105 fn glob_star_suffix() {
2106 assert!(glob_match("web-*", "web-prod-1"));
2107 assert!(glob_match("web-*", "web-staging"));
2108 assert!(!glob_match("web-*", "api-prod"));
2109 }
2110
2111 #[test]
2112 fn glob_star_prefix() {
2113 assert!(glob_match("*-prod", "web-prod"));
2114 assert!(glob_match("*-prod", "api-prod"));
2115 assert!(!glob_match("*-prod", "web-staging"));
2116 }
2117
2118 #[test]
2119 fn glob_star_middle() {
2120 assert!(glob_match("web-*-prod", "web-us-prod"));
2121 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
2122 assert!(!glob_match("web-*-prod", "web-staging"));
2123 }
2124
2125 #[test]
2126 fn glob_star_only() {
2127 assert!(glob_match("*", "anything"));
2128 assert!(glob_match("*", ""));
2129 }
2130
2131 #[test]
2132 fn glob_multiple_stars() {
2133 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
2134 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
2135 }
2136
2137 #[test]
2142 fn glob_match_multibyte_utf8() {
2143 assert!(glob_match("hé*llo", "héllo"));
2144 assert!(glob_match("*ö*", "wörld"));
2145 assert!(glob_match("über*", "übermensch"));
2146 assert!(glob_match("*界", "世界"));
2147 assert!(!glob_match("hé*llo", "hello"));
2148 assert!(!glob_match("界*", "世界"));
2149 assert!(glob_match("世*界", "世界"));
2150 }
2151
2152 #[test]
2164 fn glob_prefix_and_suffix_meet_exactly() {
2165 assert!(glob_match("ab*cd", "abcd"));
2168 }
2169
2170 #[test]
2175 fn glob_middle_segment_required_with_suffix() {
2176 assert!(!glob_match("a*b*c", "axyc"));
2181 }
2182
2183 #[test]
2189 fn glob_match_middle_advances_past_matched_part() {
2190 assert!(!glob_match("*ab*ab*", "xxab_yz"));
2195 }
2196
2197 #[test]
2202 fn glob_match_middle_uses_addition_not_multiplication() {
2203 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
2207 }
2208
2209 #[test]
2218 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
2219 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
2227 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2228 "run-*",
2229 "cmd",
2230 vec!["ls".into()],
2231 )]);
2232 let mut config = RbacConfig::with_roles(vec![role]);
2233 config.enabled = true;
2234 let policy = RbacPolicy::new(&config);
2235 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
2236 }
2237
2238 #[test]
2241 fn disabled_policy_allows_everything() {
2242 let policy = RbacPolicy::new(&RbacConfig {
2243 enabled: false,
2244 roles: vec![],
2245 redaction_salt: None,
2246 ..RbacConfig::default()
2247 });
2248 assert_eq!(
2249 policy.check("nonexistent", "resource_delete", "any-host"),
2250 RbacDecision::Allow
2251 );
2252 }
2253
2254 #[test]
2255 fn unknown_role_denied() {
2256 let policy = test_policy();
2257 assert_eq!(
2258 policy.check("unknown", "resource_list", "web-prod-1"),
2259 RbacDecision::Deny
2260 );
2261 }
2262
2263 #[test]
2264 fn viewer_allowed_read_ops() {
2265 let policy = test_policy();
2266 assert_eq!(
2267 policy.check("viewer", "resource_list", "web-prod-1"),
2268 RbacDecision::Allow
2269 );
2270 assert_eq!(
2271 policy.check("viewer", "system_info", "db-host"),
2272 RbacDecision::Allow
2273 );
2274 }
2275
2276 #[test]
2277 fn viewer_denied_write_ops() {
2278 let policy = test_policy();
2279 assert_eq!(
2280 policy.check("viewer", "resource_run", "web-prod-1"),
2281 RbacDecision::Deny
2282 );
2283 assert_eq!(
2284 policy.check("viewer", "resource_delete", "web-prod-1"),
2285 RbacDecision::Deny
2286 );
2287 }
2288
2289 #[test]
2290 fn deploy_allowed_on_matching_hosts() {
2291 let policy = test_policy();
2292 assert_eq!(
2293 policy.check("deploy", "resource_run", "web-prod-1"),
2294 RbacDecision::Allow
2295 );
2296 assert_eq!(
2297 policy.check("deploy", "resource_start", "api-staging"),
2298 RbacDecision::Allow
2299 );
2300 }
2301
2302 #[test]
2303 fn deploy_denied_on_non_matching_host() {
2304 let policy = test_policy();
2305 assert_eq!(
2306 policy.check("deploy", "resource_run", "db-prod-1"),
2307 RbacDecision::Deny
2308 );
2309 }
2310
2311 #[test]
2312 fn deny_overrides_allow() {
2313 let policy = test_policy();
2314 assert_eq!(
2315 policy.check("deploy", "resource_delete", "web-prod-1"),
2316 RbacDecision::Deny
2317 );
2318 assert_eq!(
2319 policy.check("deploy", "resource_exec", "web-prod-1"),
2320 RbacDecision::Deny
2321 );
2322 }
2323
2324 #[test]
2325 fn ops_wildcard_allows_everything() {
2326 let policy = test_policy();
2327 assert_eq!(
2328 policy.check("ops", "resource_delete", "any-host"),
2329 RbacDecision::Allow
2330 );
2331 assert_eq!(
2332 policy.check("ops", "secret_create", "db-host"),
2333 RbacDecision::Allow
2334 );
2335 }
2336
2337 #[test]
2340 fn host_visible_respects_globs() {
2341 let policy = test_policy();
2342 assert!(policy.host_visible("deploy", "web-prod-1"));
2343 assert!(policy.host_visible("deploy", "api-staging"));
2344 assert!(!policy.host_visible("deploy", "db-prod-1"));
2345 assert!(policy.host_visible("ops", "anything"));
2346 assert!(policy.host_visible("viewer", "anything"));
2347 }
2348
2349 #[test]
2350 fn host_visible_unknown_role() {
2351 let policy = test_policy();
2352 assert!(!policy.host_visible("unknown", "web-prod-1"));
2353 }
2354
2355 #[test]
2356 fn host_matching_is_ascii_case_insensitive() {
2357 let policy = test_policy();
2358 assert!(policy.host_visible("deploy", "WEB-PROD-1"));
2359 assert!(policy.host_visible("deploy", "Web-Prod-1"));
2360 assert!(policy.host_visible("deploy", "API-Staging"));
2361 assert!(!policy.host_visible("deploy", "DB-PROD-1"));
2362 }
2363
2364 #[test]
2365 fn check_host_matching_is_ascii_case_insensitive() {
2366 let policy = test_policy();
2367 assert_eq!(
2368 policy.check("deploy", "resource_run", "WEB-PROD-1"),
2369 RbacDecision::Allow
2370 );
2371 assert_eq!(
2372 policy.check("deploy", "resource_run", "DB-PROD-1"),
2373 RbacDecision::Deny
2374 );
2375 }
2376
2377 #[test]
2378 fn check_operation_names_remain_case_sensitive() {
2379 let policy = test_policy();
2380 assert_eq!(
2381 policy.check("deploy", "RESOURCE_RUN", "web-prod-1"),
2382 RbacDecision::Deny,
2383 "host normalization must not leak into operation matching"
2384 );
2385 }
2386
2387 #[test]
2388 fn tool_glob_matching_remains_case_sensitive() {
2389 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2392 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2393 "resource_*",
2394 "cmd",
2395 vec!["ls".into()],
2396 )]);
2397 let policy = RbacPolicy::new(&RbacConfig::with_roles(vec![role]));
2398
2399 assert!(policy.has_argument_allowlist("viewer", "resource_exec", "cmd"));
2400 assert!(
2401 !policy.has_argument_allowlist("viewer", "RESOURCE_EXEC", "cmd"),
2402 "tool patterns must not match case-insensitively"
2403 );
2404 assert!(!policy.argument_allowed("viewer", "resource_exec", "cmd", "rm"));
2405 }
2406
2407 #[test]
2410 fn argument_allowed_no_allowlist() {
2411 let policy = test_policy();
2412 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
2414 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
2415 }
2416
2417 #[test]
2418 fn argument_allowed_with_allowlist() {
2419 let policy = test_policy();
2420 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
2421 assert!(policy.argument_allowed(
2422 "restricted-exec",
2423 "resource_exec",
2424 "cmd",
2425 "bash -c 'echo hi'"
2426 ));
2427 assert!(policy.argument_allowed(
2428 "restricted-exec",
2429 "resource_exec",
2430 "cmd",
2431 "cat /etc/hosts"
2432 ));
2433 assert!(policy.argument_allowed(
2434 "restricted-exec",
2435 "resource_exec",
2436 "cmd",
2437 "/usr/bin/ls -la"
2438 ));
2439 }
2440
2441 #[test]
2442 fn argument_denied_not_in_allowlist() {
2443 let policy = test_policy();
2444 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
2445 assert!(!policy.argument_allowed(
2446 "restricted-exec",
2447 "resource_exec",
2448 "cmd",
2449 "python3 exploit.py"
2450 ));
2451 assert!(!policy.argument_allowed(
2452 "restricted-exec",
2453 "resource_exec",
2454 "cmd",
2455 "/usr/bin/curl evil.com"
2456 ));
2457 }
2458
2459 #[test]
2460 fn argument_denied_unknown_role() {
2461 let policy = test_policy();
2462 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
2463 }
2464
2465 fn strict_test_policy(allowlists: Vec<ArgumentAllowlist>) -> RbacPolicy {
2468 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2469 .with_argument_allowlists(allowlists);
2470 let mut config = RbacConfig::with_roles(vec![role]);
2471 config.enabled = true;
2472 RbacPolicy::new(&config)
2473 }
2474
2475 fn tool_call(args: serde_json::Value) -> serde_json::Value {
2476 let mut params = serde_json::Map::new();
2477 params.insert(
2478 "name".to_owned(),
2479 serde_json::Value::String("run".to_owned()),
2480 );
2481 params.insert("arguments".to_owned(), args);
2482 serde_json::Value::Object(params)
2483 }
2484
2485 #[test]
2486 fn unknown_arguments_are_admitted_when_strict_mode_is_off() {
2487 let policy = strict_test_policy(vec![ArgumentAllowlist::new(
2488 "run",
2489 "cmd",
2490 vec!["ls".into()],
2491 )]);
2492 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2493 assert!(
2494 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2495 "default behaviour must be unchanged: unnamed arguments pass"
2496 );
2497 }
2498
2499 #[test]
2500 fn strict_mode_rejects_unknown_arguments() {
2501 let policy = strict_test_policy(vec![
2502 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2503 .with_deny_unknown_arguments(true),
2504 ]);
2505 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2506 assert!(
2507 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2508 "an argument no allowlist names must be denied under strict mode"
2509 );
2510
2511 let permitted = tool_call(serde_json::json!({ "cmd": "ls" }));
2512 assert!(
2513 enforce_tool_policy(&policy, "u", "viewer", &permitted).is_none(),
2514 "an allowlisted argument must still pass"
2515 );
2516 }
2517
2518 #[test]
2519 fn strict_mode_rejects_structured_argument_values() {
2520 let policy = strict_test_policy(vec![
2521 ArgumentAllowlist::new("run", "cmd", vec![]).with_deny_unknown_arguments(true),
2522 ]);
2523 for shape in [
2524 serde_json::json!({ "nested": "x" }),
2525 serde_json::json!(["x"]),
2526 ] {
2527 let params = tool_call(serde_json::json!({ "cmd": shape }));
2528 assert!(
2529 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2530 "object/array values cannot be constrained and must be denied"
2531 );
2532 }
2533 }
2534
2535 #[test]
2536 fn strict_mode_permits_the_union_of_matching_allowlists() {
2537 let policy = strict_test_policy(vec![
2540 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2541 .with_deny_unknown_arguments(true),
2542 ArgumentAllowlist::new("run", "host", vec![]),
2543 ]);
2544 let params = tool_call(serde_json::json!({ "cmd": "ls", "host": "dev-1" }));
2545 assert!(
2546 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2547 "every matching allowlist's argument must remain permitted"
2548 );
2549 }
2550
2551 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
2560 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2561 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
2562 let mut config = RbacConfig::with_roles(vec![role]);
2563 config.enabled = true;
2564 RbacPolicy::new(&config)
2565 }
2566
2567 #[test]
2568 fn argument_allowed_matches_quoted_path_with_spaces() {
2569 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
2570 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2571 }
2572
2573 #[test]
2574 fn argument_allowed_matches_basename_of_quoted_path() {
2575 let policy = shlex_policy(vec!["my tool".into()]);
2576 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2577 }
2578
2579 #[test]
2580 fn argument_allowed_fails_closed_on_unbalanced_quote() {
2581 let policy = shlex_policy(vec!["unbalanced".into()]);
2582 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
2583 }
2584
2585 #[test]
2586 fn argument_allowed_fails_closed_on_empty_string() {
2587 let policy = shlex_policy(vec![String::new()]);
2588 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
2589 }
2590
2591 #[test]
2592 fn argument_allowed_handles_single_quoted_executable() {
2593 let policy = shlex_policy(vec!["/bin/sh".into()]);
2594 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
2595 }
2596
2597 #[test]
2598 fn argument_allowed_handles_tab_separator() {
2599 let policy = shlex_policy(vec!["ls".into()]);
2600 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
2601 }
2602
2603 #[test]
2604 fn argument_allowed_plain_token_unchanged() {
2605 let policy = shlex_policy(vec!["ls".into()]);
2606 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
2607 }
2608
2609 #[test]
2615 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
2616 let policy = shlex_policy(vec![String::new()]);
2620 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
2621 }
2622
2623 #[test]
2624 fn argument_allowed_quoted_literal_token_no_longer_matches() {
2625 let policy = shlex_policy(vec!["'bash'".into()]);
2631 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
2632 }
2633
2634 #[test]
2635 fn argument_allowed_backslash_literal_token_no_longer_matches() {
2636 let policy = shlex_policy(vec![r"foo\bar".into()]);
2641 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
2642 }
2643
2644 #[test]
2645 fn argument_allowed_windows_path_no_longer_matches() {
2646 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
2651 assert!(!policy.argument_allowed(
2652 "viewer",
2653 "run",
2654 "cmd",
2655 r"C:\Windows\System32\cmd.exe /c dir"
2656 ));
2657 }
2658
2659 #[test]
2662 fn host_patterns_returns_globs() {
2663 let policy = test_policy();
2664 assert_eq!(
2665 policy.host_patterns("deploy"),
2666 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
2667 );
2668 assert_eq!(
2669 policy.host_patterns("ops"),
2670 Some(vec!["*".to_owned()].as_slice())
2671 );
2672 assert!(policy.host_patterns("nonexistent").is_none());
2673 }
2674
2675 #[test]
2678 fn check_operation_allows_without_host() {
2679 let policy = test_policy();
2680 assert_eq!(
2681 policy.check_operation("deploy", "resource_run"),
2682 RbacDecision::Allow
2683 );
2684 assert_eq!(
2686 policy.check("deploy", "resource_run", "db-prod-1"),
2687 RbacDecision::Deny
2688 );
2689 }
2690
2691 #[test]
2692 fn check_operation_deny_overrides() {
2693 let policy = test_policy();
2694 assert_eq!(
2695 policy.check_operation("deploy", "resource_delete"),
2696 RbacDecision::Deny
2697 );
2698 }
2699
2700 #[test]
2701 fn check_operation_unknown_role() {
2702 let policy = test_policy();
2703 assert_eq!(
2704 policy.check_operation("unknown", "resource_list"),
2705 RbacDecision::Deny
2706 );
2707 }
2708
2709 #[test]
2710 fn check_operation_disabled() {
2711 let policy = RbacPolicy::new(&RbacConfig {
2712 enabled: false,
2713 roles: vec![],
2714 redaction_salt: None,
2715 ..RbacConfig::default()
2716 });
2717 assert_eq!(
2718 policy.check_operation("nonexistent", "anything"),
2719 RbacDecision::Allow
2720 );
2721 }
2722
2723 fn op_policy(role: RoleConfig) -> RbacPolicy {
2726 RbacPolicy::new(&RbacConfig::with_roles(vec![role]))
2727 }
2728
2729 fn glob_op_policy(role: RoleConfig) -> RbacPolicy {
2730 RbacPolicy::new(
2731 &RbacConfig::with_roles(vec![role])
2732 .with_allow_operation_matching(AllowOperationMatching::Glob),
2733 )
2734 }
2735
2736 #[test]
2737 fn deny_glob_blocks_under_allow_all() {
2738 let policy = op_policy(
2739 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2740 .with_deny(vec!["*_delete_*".into()]),
2741 );
2742 assert_eq!(
2743 policy.check_operation("editor", "jira_delete_issue"),
2744 RbacDecision::Deny
2745 );
2746 assert_eq!(
2747 policy.check_operation("editor", "confluence_delete_page"),
2748 RbacDecision::Deny
2749 );
2750 assert_eq!(
2751 policy.check_operation("editor", "jira_get_issue"),
2752 RbacDecision::Allow
2753 );
2754 }
2755
2756 #[test]
2757 fn deny_glob_blocks_in_host_scoped_check() {
2758 let policy = op_policy(
2759 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2760 .with_deny(vec!["jira_delete_*".into()]),
2761 );
2762 assert_eq!(
2763 policy.check("editor", "jira_delete_issue", "web-prod"),
2764 RbacDecision::Deny
2765 );
2766 assert_eq!(
2767 policy.check("editor", "jira_get_issue", "web-prod"),
2768 RbacDecision::Allow
2769 );
2770 }
2771
2772 #[test]
2773 fn deny_without_glob_still_matches_exactly() {
2774 let policy = op_policy(
2775 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2776 .with_deny(vec!["delete".into()]),
2777 );
2778 assert_eq!(
2779 policy.check_operation("editor", "delete"),
2780 RbacDecision::Deny
2781 );
2782 assert_eq!(
2783 policy.check_operation("editor", "delete_thing"),
2784 RbacDecision::Allow
2785 );
2786 assert_eq!(
2787 policy.check_operation("editor", "soft_delete"),
2788 RbacDecision::Allow
2789 );
2790 }
2791
2792 #[test]
2793 fn allow_glob_is_inert_in_legacy_mode() {
2794 let policy = op_policy(RoleConfig::new(
2795 "reader",
2796 vec!["jira_get_*".into()],
2797 vec!["*".into()],
2798 ));
2799 assert_eq!(
2800 policy.check_operation("reader", "jira_get_issue"),
2801 RbacDecision::Deny
2802 );
2803 assert_eq!(
2804 policy.check_operation("reader", "jira_get_*"),
2805 RbacDecision::Allow
2806 );
2807 }
2808
2809 #[test]
2810 fn allow_glob_is_honored_in_glob_mode() {
2811 let policy = glob_op_policy(RoleConfig::new(
2812 "reader",
2813 vec!["jira_get_*".into()],
2814 vec!["*".into()],
2815 ));
2816 assert_eq!(
2817 policy.check_operation("reader", "jira_get_issue"),
2818 RbacDecision::Allow
2819 );
2820 assert_eq!(
2821 policy.check_operation("reader", "confluence_get_page"),
2822 RbacDecision::Deny
2823 );
2824 }
2825
2826 #[test]
2827 fn allow_glob_mode_preserves_case_sensitivity() {
2828 let policy = glob_op_policy(RoleConfig::new(
2829 "reader",
2830 vec!["Jira_*".into()],
2831 vec!["*".into()],
2832 ));
2833 assert_eq!(
2834 policy.check_operation("reader", "jira_get_issue"),
2835 RbacDecision::Deny
2836 );
2837 assert_eq!(
2838 policy.check_operation("reader", "Jira_get_issue"),
2839 RbacDecision::Allow
2840 );
2841 }
2842
2843 #[test]
2844 fn allow_exact_entries_behave_identically_in_both_modes() {
2845 let role = RoleConfig::new(
2846 "reader",
2847 vec!["ping".into(), "list_hosts".into()],
2848 vec!["*".into()],
2849 );
2850 let legacy = op_policy(role.clone());
2851 let glob = glob_op_policy(role);
2852 for op in ["ping", "list_hosts", "delete", "pin", "pingg"] {
2853 assert_eq!(
2854 legacy.check_operation("reader", op),
2855 glob.check_operation("reader", op),
2856 "mode divergence on glob-free allow entry for {op}"
2857 );
2858 }
2859 }
2860
2861 #[test]
2862 fn allow_star_means_all_operations_in_both_modes() {
2863 let role = RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]);
2864 for policy in [op_policy(role.clone()), glob_op_policy(role)] {
2865 assert_eq!(
2866 policy.check_operation("admin", "anything_at_all"),
2867 RbacDecision::Allow
2868 );
2869 }
2870 }
2871
2872 #[test]
2873 fn global_deny_vetoes_allow_all() {
2874 let policy = RbacPolicy::new(
2875 &RbacConfig::with_roles(vec![RoleConfig::new(
2876 "admin",
2877 vec!["*".into()],
2878 vec!["*".into()],
2879 )])
2880 .with_global_deny(vec!["*_delete_*".into()]),
2881 );
2882 assert_eq!(
2883 policy.check_operation("admin", "jira_delete_issue"),
2884 RbacDecision::Deny
2885 );
2886 assert_eq!(
2887 policy.check("admin", "jira_delete_issue", "web-prod"),
2888 RbacDecision::Deny
2889 );
2890 assert_eq!(
2891 policy.check_operation("admin", "jira_get_issue"),
2892 RbacDecision::Allow
2893 );
2894 }
2895
2896 #[test]
2897 fn global_deny_globs_even_in_legacy_allow_mode() {
2898 let policy = RbacPolicy::new(
2899 &RbacConfig::with_roles(vec![RoleConfig::new(
2900 "admin",
2901 vec!["*".into()],
2902 vec!["*".into()],
2903 )])
2904 .with_allow_operation_matching(AllowOperationMatching::Legacy)
2905 .with_global_deny(vec!["danger_*".into()]),
2906 );
2907 assert_eq!(
2908 policy.check_operation("admin", "danger_wipe"),
2909 RbacDecision::Deny
2910 );
2911 }
2912
2913 #[test]
2914 fn global_deny_is_inert_when_rbac_disabled() {
2915 let policy = RbacPolicy::new(&RbacConfig {
2916 enabled: false,
2917 global_deny: vec!["*".into()],
2918 ..RbacConfig::default()
2919 });
2920 assert_eq!(
2921 policy.check_operation("anyone", "anything"),
2922 RbacDecision::Allow
2923 );
2924 }
2925
2926 #[test]
2927 fn global_deny_defaults_to_empty_and_changes_nothing() {
2928 let policy = op_policy(RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]));
2929 assert_eq!(
2930 policy.check_operation("admin", "jira_delete_issue"),
2931 RbacDecision::Allow
2932 );
2933 assert_eq!(policy.summary().global_deny, 0);
2934 }
2935
2936 #[test]
2937 fn empty_deny_entry_denies_only_the_empty_operation() {
2938 let policy = op_policy(
2939 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2940 .with_deny(vec![String::new()]),
2941 );
2942 assert_eq!(policy.check_operation("editor", ""), RbacDecision::Deny);
2943 assert_eq!(
2944 policy.check_operation("editor", "anything"),
2945 RbacDecision::Allow
2946 );
2947 }
2948
2949 #[test]
2950 fn empty_global_deny_entry_denies_only_the_empty_operation() {
2951 let policy = RbacPolicy::new(
2952 &RbacConfig::with_roles(vec![RoleConfig::new(
2953 "admin",
2954 vec!["*".into()],
2955 vec!["*".into()],
2956 )])
2957 .with_global_deny(vec![String::new()]),
2958 );
2959 assert_eq!(policy.check_operation("admin", ""), RbacDecision::Deny);
2960 assert_eq!(
2961 policy.check_operation("admin", "anything"),
2962 RbacDecision::Allow
2963 );
2964 }
2965
2966 #[test]
2967 fn star_deny_entry_denies_every_operation() {
2968 let policy = op_policy(
2969 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2970 .with_deny(vec!["*".into()]),
2971 );
2972 for op in ["", "ping", "jira_delete_issue"] {
2973 assert_eq!(policy.check_operation("editor", op), RbacDecision::Deny);
2974 assert_eq!(policy.check("editor", op, "web-prod"), RbacDecision::Deny);
2975 }
2976 }
2977
2978 #[test]
2979 fn star_global_deny_entry_denies_every_operation() {
2980 let policy = RbacPolicy::new(
2981 &RbacConfig::with_roles(vec![RoleConfig::new(
2982 "admin",
2983 vec!["*".into()],
2984 vec!["*".into()],
2985 )])
2986 .with_global_deny(vec!["*".into()]),
2987 );
2988 for op in ["", "ping", "jira_delete_issue"] {
2989 assert_eq!(policy.check_operation("admin", op), RbacDecision::Deny);
2990 }
2991 }
2992
2993 #[test]
2994 fn legacy_allow_matches_a_literal_star_in_an_operation_name() {
2995 let policy = op_policy(RoleConfig::new(
2996 "odd",
2997 vec!["weird_*_name".into()],
2998 vec!["*".into()],
2999 ));
3000 assert_eq!(
3001 policy.check_operation("odd", "weird_*_name"),
3002 RbacDecision::Allow
3003 );
3004 assert_eq!(
3005 policy.check_operation("odd", "weird_thing_name"),
3006 RbacDecision::Deny
3007 );
3008 }
3009
3010 #[test]
3011 fn deny_glob_matches_multibyte_operation_names() {
3012 let policy = op_policy(
3013 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
3014 .with_deny(vec!["削除_*".into()]),
3015 );
3016 assert_eq!(
3017 policy.check_operation("editor", "削除_ページ"),
3018 RbacDecision::Deny
3019 );
3020 assert_eq!(
3021 policy.check_operation("editor", "取得_ページ"),
3022 RbacDecision::Allow
3023 );
3024 }
3025
3026 #[test]
3027 fn operation_matching_fields_deserialize_from_toml() {
3028 let cfg: RbacConfig = toml::from_str(
3029 r#"
3030 enabled = true
3031 allow_operation_matching = "glob"
3032 global_deny = ["*_purge_*"]
3033
3034 [[roles]]
3035 name = "ops"
3036 allow = ["jira_*"]
3037 hosts = ["*"]
3038 "#,
3039 )
3040 .expect("config parses");
3041 assert_eq!(
3042 cfg.allow_operation_matching,
3043 AllowOperationMatching::Glob,
3044 "kebab-case wire value must map to the Glob variant"
3045 );
3046 assert_eq!(cfg.global_deny, vec!["*_purge_*".to_owned()]);
3047
3048 let policy = RbacPolicy::new(&cfg);
3049 assert_eq!(
3050 policy.check_operation("ops", "jira_get_issue"),
3051 RbacDecision::Allow
3052 );
3053 assert_eq!(
3054 policy.check_operation("ops", "jira_purge_project"),
3055 RbacDecision::Deny
3056 );
3057 }
3058
3059 #[test]
3060 fn operation_matching_defaults_to_legacy_when_absent_from_toml() {
3061 let cfg: RbacConfig = toml::from_str("enabled = true").expect("config parses");
3062 assert_eq!(cfg.allow_operation_matching, AllowOperationMatching::Legacy);
3063 assert!(cfg.global_deny.is_empty());
3064 }
3065
3066 #[test]
3069 fn current_role_returns_none_outside_scope() {
3070 assert!(current_role().is_none());
3071 }
3072
3073 #[test]
3074 fn current_identity_returns_none_outside_scope() {
3075 assert!(current_identity().is_none());
3076 }
3077
3078 #[tokio::test]
3079 async fn empty_task_locals_are_all_absent() {
3080 with_rbac_scope(
3081 String::new(),
3082 String::new(),
3083 SecretString::from(String::new()),
3084 String::new(),
3085 async {
3086 assert!(current_role().is_none(), "empty role must be absent");
3087 assert!(
3088 current_identity().is_none(),
3089 "empty identity must be absent"
3090 );
3091 assert!(current_token().is_none(), "empty token must be absent");
3092 assert!(current_sub().is_none(), "empty sub must be absent");
3093 },
3094 )
3095 .await;
3096 }
3097
3098 #[tokio::test]
3099 async fn non_empty_task_locals_are_all_present() {
3100 with_rbac_scope(
3101 "viewer".to_owned(),
3102 "alice".to_owned(),
3103 SecretString::from("tok".to_owned()),
3104 "sub-1".to_owned(),
3105 async {
3106 assert_eq!(current_role().as_deref(), Some("viewer"));
3107 assert_eq!(current_identity().as_deref(), Some("alice"));
3108 assert!(current_token().is_some());
3109 assert_eq!(current_sub().as_deref(), Some("sub-1"));
3110 },
3111 )
3112 .await;
3113 }
3114
3115 #[tokio::test]
3120 async fn sub_or_identity_fallback_is_absent_for_empty_identity() {
3121 with_rbac_scope(
3122 "viewer".to_owned(),
3123 String::new(),
3124 SecretString::from(String::new()),
3125 String::new(),
3126 async {
3127 assert_eq!(current_role().as_deref(), Some("viewer"));
3128 assert!(
3129 current_sub().or_else(current_identity).is_none(),
3130 "empty identity must not satisfy a sub-or-identity fallback"
3131 );
3132 },
3133 )
3134 .await;
3135 }
3136
3137 use axum::{
3140 body::Body,
3141 http::{Method, Request, StatusCode},
3142 };
3143 use tower::ServiceExt as _;
3144
3145 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
3146 serde_json::json!({
3147 "jsonrpc": "2.0",
3148 "id": 1,
3149 "method": "tools/call",
3150 "params": {
3151 "name": tool,
3152 "arguments": args
3153 }
3154 })
3155 .to_string()
3156 }
3157
3158 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
3159 axum::Router::new()
3160 .route("/mcp", axum::routing::post(|| async { "ok" }))
3161 .layer(axum::middleware::from_fn(move |req, next| {
3162 let p = Arc::clone(&policy);
3163 rbac_middleware(p, None, req, next)
3164 }))
3165 }
3166
3167 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
3168 axum::Router::new()
3169 .route("/mcp", axum::routing::post(|| async { "ok" }))
3170 .layer(axum::middleware::from_fn(
3171 move |mut req: Request<Body>, next: Next| {
3172 let p = Arc::clone(&policy);
3173 let id = identity.clone();
3174 async move {
3175 req.extensions_mut().insert(id);
3176 rbac_middleware(p, None, req, next).await
3177 }
3178 },
3179 ))
3180 }
3181
3182 #[cfg(feature = "metrics")]
3186 #[tokio::test]
3187 async fn tool_limiter_deny_increments_counter() {
3188 use axum::extract::ConnectInfo;
3189
3190 let policy = Arc::new(test_policy());
3191 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
3192 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
3193 let identity = AuthIdentity {
3194 method: crate::auth::AuthMethod::BearerToken,
3195 name: "alice".into(),
3196 role: "viewer".into(),
3197 raw_token: None,
3198 sub: None,
3199 };
3200 let app = {
3201 let metrics = Arc::clone(&metrics);
3202 axum::Router::new()
3203 .route("/mcp", axum::routing::post(|| async { "ok" }))
3204 .layer(axum::middleware::from_fn(
3205 move |mut req: Request<Body>, next: Next| {
3206 let p = Arc::clone(&policy);
3207 let l = Arc::clone(&limiter);
3208 let id = identity.clone();
3209 let m = Arc::clone(&metrics);
3210 async move {
3211 req.extensions_mut().insert(id);
3212 req.extensions_mut().insert(m);
3213 let peer: std::net::SocketAddr =
3214 "10.9.9.1:40000".parse().expect("static socket addr parses");
3215 req.extensions_mut().insert(ConnectInfo(peer));
3216 rbac_middleware(p, Some(l), req, next).await
3217 }
3218 },
3219 ))
3220 };
3221 let mk = || {
3222 Request::builder()
3223 .method(Method::POST)
3224 .uri("/mcp")
3225 .header("content-type", "application/json")
3226 .body(Body::from(tool_call_body(
3227 "resource_list",
3228 &serde_json::json!({}),
3229 )))
3230 .unwrap()
3231 };
3232 let counter = || {
3233 metrics
3234 .rate_limited_total
3235 .with_label_values(&["tool"])
3236 .get()
3237 };
3238
3239 let first = app.clone().oneshot(mk()).await.unwrap();
3240 assert_eq!(first.status(), StatusCode::OK);
3241 assert_eq!(counter(), 0, "successful call must not count");
3242
3243 let denied = app.clone().oneshot(mk()).await.unwrap();
3244 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
3245 assert_eq!(counter(), 1, "deny must increment the tool label");
3246 }
3247
3248 #[tokio::test]
3249 async fn middleware_passes_non_post() {
3250 let policy = Arc::new(test_policy());
3251 let app = rbac_router(policy);
3252 let req = Request::builder()
3254 .method(Method::GET)
3255 .uri("/mcp")
3256 .body(Body::empty())
3257 .unwrap();
3258 let resp = app.oneshot(req).await.unwrap();
3261 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
3262 }
3263
3264 #[tokio::test]
3265 async fn middleware_denies_without_identity() {
3266 let policy = Arc::new(test_policy());
3267 let app = rbac_router(policy);
3268 let body = tool_call_body("resource_list", &serde_json::json!({}));
3269 let req = Request::builder()
3270 .method(Method::POST)
3271 .uri("/mcp")
3272 .header("content-type", "application/json")
3273 .body(Body::from(body))
3274 .unwrap();
3275 let resp = app.oneshot(req).await.unwrap();
3276 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3277 }
3278
3279 fn global_deny_identity() -> AuthIdentity {
3280 AuthIdentity {
3281 method: crate::auth::AuthMethod::BearerToken,
3282 name: "alice".into(),
3283 role: "admin".into(),
3284 raw_token: None,
3285 sub: None,
3286 }
3287 }
3288
3289 fn global_deny_policy() -> Arc<RbacPolicy> {
3290 Arc::new(RbacPolicy::new(
3291 &RbacConfig::with_roles(vec![RoleConfig::new(
3292 "admin",
3293 vec!["*".into()],
3294 vec!["*".into()],
3295 )])
3296 .with_global_deny(vec!["*_delete_*".into()]),
3297 ))
3298 }
3299
3300 async fn global_deny_call(args: serde_json::Value, tool: &str) -> StatusCode {
3301 let app = rbac_router_with_identity(global_deny_policy(), global_deny_identity());
3302 let req = Request::builder()
3303 .method(Method::POST)
3304 .uri("/mcp")
3305 .header("content-type", "application/json")
3306 .body(Body::from(tool_call_body(tool, &args)))
3307 .unwrap();
3308 app.oneshot(req).await.unwrap().status()
3309 }
3310
3311 #[tokio::test]
3312 async fn middleware_global_deny_blocks_hostless_tool_call() {
3313 assert_eq!(
3314 global_deny_call(serde_json::json!({}), "jira_delete_issue").await,
3315 StatusCode::FORBIDDEN
3316 );
3317 assert_eq!(
3318 global_deny_call(serde_json::json!({}), "jira_get_issue").await,
3319 StatusCode::OK
3320 );
3321 }
3322
3323 #[tokio::test]
3324 async fn middleware_global_deny_blocks_host_scoped_tool_call() {
3325 assert_eq!(
3326 global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_delete_issue").await,
3327 StatusCode::FORBIDDEN
3328 );
3329 assert_eq!(
3330 global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_get_issue").await,
3331 StatusCode::OK
3332 );
3333 }
3334
3335 #[tokio::test]
3336 async fn middleware_allows_permitted_tool() {
3337 let policy = Arc::new(test_policy());
3338 let id = AuthIdentity {
3339 method: crate::auth::AuthMethod::BearerToken,
3340 name: "alice".into(),
3341 role: "viewer".into(),
3342 raw_token: None,
3343 sub: None,
3344 };
3345 let app = rbac_router_with_identity(policy, id);
3346 let body = tool_call_body("resource_list", &serde_json::json!({}));
3347 let req = Request::builder()
3348 .method(Method::POST)
3349 .uri("/mcp")
3350 .header("content-type", "application/json")
3351 .body(Body::from(body))
3352 .unwrap();
3353 let resp = app.oneshot(req).await.unwrap();
3354 assert_eq!(resp.status(), StatusCode::OK);
3355 }
3356
3357 #[tokio::test]
3358 async fn middleware_denies_unpermitted_tool() {
3359 let policy = Arc::new(test_policy());
3360 let id = AuthIdentity {
3361 method: crate::auth::AuthMethod::BearerToken,
3362 name: "alice".into(),
3363 role: "viewer".into(),
3364 raw_token: None,
3365 sub: None,
3366 };
3367 let app = rbac_router_with_identity(policy, id);
3368 let body = tool_call_body("resource_delete", &serde_json::json!({}));
3369 let req = Request::builder()
3370 .method(Method::POST)
3371 .uri("/mcp")
3372 .header("content-type", "application/json")
3373 .body(Body::from(body))
3374 .unwrap();
3375 let resp = app.oneshot(req).await.unwrap();
3376 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3377 }
3378
3379 #[tokio::test]
3380 async fn middleware_passes_non_tool_call_post() {
3381 let policy = Arc::new(test_policy());
3382 let id = AuthIdentity {
3383 method: crate::auth::AuthMethod::BearerToken,
3384 name: "alice".into(),
3385 role: "viewer".into(),
3386 raw_token: None,
3387 sub: None,
3388 };
3389 let app = rbac_router_with_identity(policy, id);
3390 let body = serde_json::json!({
3392 "jsonrpc": "2.0",
3393 "id": 1,
3394 "method": "resources/list"
3395 })
3396 .to_string();
3397 let req = Request::builder()
3398 .method(Method::POST)
3399 .uri("/mcp")
3400 .header("content-type", "application/json")
3401 .body(Body::from(body))
3402 .unwrap();
3403 let resp = app.oneshot(req).await.unwrap();
3404 assert_eq!(resp.status(), StatusCode::OK);
3405 }
3406
3407 #[tokio::test]
3408 async fn middleware_enforces_argument_allowlist() {
3409 let policy = Arc::new(test_policy());
3410 let id = AuthIdentity {
3411 method: crate::auth::AuthMethod::BearerToken,
3412 name: "dev".into(),
3413 role: "restricted-exec".into(),
3414 raw_token: None,
3415 sub: None,
3416 };
3417 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
3419 let body = tool_call_body(
3420 "resource_exec",
3421 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
3422 );
3423 let req = Request::builder()
3424 .method(Method::POST)
3425 .uri("/mcp")
3426 .body(Body::from(body))
3427 .unwrap();
3428 let resp = app.oneshot(req).await.unwrap();
3429 assert_eq!(resp.status(), StatusCode::OK);
3430
3431 let app = rbac_router_with_identity(policy, id);
3433 let body = tool_call_body(
3434 "resource_exec",
3435 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
3436 );
3437 let req = Request::builder()
3438 .method(Method::POST)
3439 .uri("/mcp")
3440 .body(Body::from(body))
3441 .unwrap();
3442 let resp = app.oneshot(req).await.unwrap();
3443 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3444 }
3445
3446 #[tokio::test]
3447 async fn middleware_disabled_policy_passes_everything() {
3448 let policy = Arc::new(RbacPolicy::disabled());
3449 let app = rbac_router(policy);
3450 let body = tool_call_body("anything", &serde_json::json!({}));
3452 let req = Request::builder()
3453 .method(Method::POST)
3454 .uri("/mcp")
3455 .body(Body::from(body))
3456 .unwrap();
3457 let resp = app.oneshot(req).await.unwrap();
3458 assert_eq!(resp.status(), StatusCode::OK);
3459 }
3460
3461 #[tokio::test]
3462 async fn middleware_batch_all_allowed_passes() {
3463 let policy = Arc::new(test_policy());
3464 let id = AuthIdentity {
3465 method: crate::auth::AuthMethod::BearerToken,
3466 name: "alice".into(),
3467 role: "viewer".into(),
3468 raw_token: None,
3469 sub: None,
3470 };
3471 let app = rbac_router_with_identity(policy, id);
3472 let body = serde_json::json!([
3473 {
3474 "jsonrpc": "2.0",
3475 "id": 1,
3476 "method": "tools/call",
3477 "params": { "name": "resource_list", "arguments": {} }
3478 },
3479 {
3480 "jsonrpc": "2.0",
3481 "id": 2,
3482 "method": "tools/call",
3483 "params": { "name": "system_info", "arguments": {} }
3484 }
3485 ])
3486 .to_string();
3487 let req = Request::builder()
3488 .method(Method::POST)
3489 .uri("/mcp")
3490 .header("content-type", "application/json")
3491 .body(Body::from(body))
3492 .unwrap();
3493 let resp = app.oneshot(req).await.unwrap();
3494 assert_eq!(resp.status(), StatusCode::OK);
3495 }
3496
3497 #[tokio::test]
3498 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
3499 let policy = Arc::new(test_policy());
3500 let id = AuthIdentity {
3501 method: crate::auth::AuthMethod::BearerToken,
3502 name: "alice".into(),
3503 role: "viewer".into(),
3504 raw_token: None,
3505 sub: None,
3506 };
3507 let app = rbac_router_with_identity(policy, id);
3508 let body = serde_json::json!([
3509 {
3510 "jsonrpc": "2.0",
3511 "id": 1,
3512 "method": "tools/call",
3513 "params": { "name": "resource_list", "arguments": {} }
3514 },
3515 {
3516 "jsonrpc": "2.0",
3517 "id": 2,
3518 "method": "tools/call",
3519 "params": { "name": "resource_delete", "arguments": {} }
3520 }
3521 ])
3522 .to_string();
3523 let req = Request::builder()
3524 .method(Method::POST)
3525 .uri("/mcp")
3526 .header("content-type", "application/json")
3527 .body(Body::from(body))
3528 .unwrap();
3529 let resp = app.oneshot(req).await.unwrap();
3530 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3531 }
3532
3533 #[tokio::test]
3534 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
3535 let policy = Arc::new(test_policy());
3536 let id = AuthIdentity {
3537 method: crate::auth::AuthMethod::BearerToken,
3538 name: "dev".into(),
3539 role: "restricted-exec".into(),
3540 raw_token: None,
3541 sub: None,
3542 };
3543 let app = rbac_router_with_identity(policy, id);
3544 let body = serde_json::json!([
3545 {
3546 "jsonrpc": "2.0",
3547 "id": 1,
3548 "method": "tools/call",
3549 "params": {
3550 "name": "resource_exec",
3551 "arguments": { "cmd": "ls -la", "host": "dev-1" }
3552 }
3553 },
3554 {
3555 "jsonrpc": "2.0",
3556 "id": 2,
3557 "method": "tools/call",
3558 "params": {
3559 "name": "resource_exec",
3560 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
3561 }
3562 }
3563 ])
3564 .to_string();
3565 let req = Request::builder()
3566 .method(Method::POST)
3567 .uri("/mcp")
3568 .header("content-type", "application/json")
3569 .body(Body::from(body))
3570 .unwrap();
3571 let resp = app.oneshot(req).await.unwrap();
3572 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3573 }
3574
3575 #[test]
3578 fn redact_with_salt_is_deterministic_per_salt() {
3579 let salt = b"unit-test-salt";
3580 let a = redact_with_salt(salt, "rm -rf /");
3581 let b = redact_with_salt(salt, "rm -rf /");
3582 assert_eq!(a, b, "same input + salt must yield identical hash");
3583 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
3584 assert!(
3585 a.chars().all(|c| c.is_ascii_hexdigit()),
3586 "redacted hash must be lowercase hex: {a}"
3587 );
3588 }
3589
3590 #[test]
3591 fn redact_with_salt_differs_across_salts() {
3592 let v = "the-same-value";
3593 let h1 = redact_with_salt(b"salt-one", v);
3594 let h2 = redact_with_salt(b"salt-two", v);
3595 assert_ne!(
3596 h1, h2,
3597 "different salts must produce different hashes for the same value"
3598 );
3599 }
3600
3601 #[test]
3602 fn redact_with_salt_distinguishes_values() {
3603 let salt = b"k";
3604 let h1 = redact_with_salt(salt, "alpha");
3605 let h2 = redact_with_salt(salt, "beta");
3606 assert_ne!(h1, h2, "different values must produce different hashes");
3608 }
3609
3610 #[test]
3611 fn policy_with_configured_salt_redacts_consistently() {
3612 let cfg = RbacConfig {
3613 enabled: true,
3614 roles: vec![],
3615 redaction_salt: Some(SecretString::from("my-stable-salt")),
3616 ..RbacConfig::default()
3617 };
3618 let p1 = RbacPolicy::new(&cfg);
3619 let p2 = RbacPolicy::new(&cfg);
3620 assert_eq!(
3621 p1.redact_arg("payload"),
3622 p2.redact_arg("payload"),
3623 "policies built from the same configured salt must agree"
3624 );
3625 }
3626
3627 #[test]
3628 fn policy_without_configured_salt_uses_process_salt() {
3629 let cfg = RbacConfig {
3630 enabled: true,
3631 roles: vec![],
3632 redaction_salt: None,
3633 ..RbacConfig::default()
3634 };
3635 let p1 = RbacPolicy::new(&cfg);
3636 let p2 = RbacPolicy::new(&cfg);
3637 assert_eq!(
3639 p1.redact_arg("payload"),
3640 p2.redact_arg("payload"),
3641 "process-wide salt must be consistent within one process"
3642 );
3643 }
3644
3645 #[tokio::test]
3657 async fn deny_path_uses_explicit_identity_not_task_local() {
3658 let policy = Arc::new(test_policy());
3659 let id = AuthIdentity {
3660 method: crate::auth::AuthMethod::BearerToken,
3661 name: "alice-the-auditor".into(),
3662 role: "viewer".into(),
3663 raw_token: None,
3664 sub: None,
3665 };
3666 let app = rbac_router_with_identity(policy, id);
3667 let body = tool_call_body("resource_delete", &serde_json::json!({}));
3669 let req = Request::builder()
3670 .method(Method::POST)
3671 .uri("/mcp")
3672 .header("content-type", "application/json")
3673 .body(Body::from(body))
3674 .unwrap();
3675 let resp = app.oneshot(req).await.unwrap();
3676 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3677 }
3678
3679 fn restricted_exec_identity() -> AuthIdentity {
3682 AuthIdentity {
3683 method: crate::auth::AuthMethod::BearerToken,
3684 name: "carol".into(),
3685 role: "restricted-exec".into(),
3686 raw_token: None,
3687 sub: None,
3688 }
3689 }
3690
3691 #[test]
3692 fn has_argument_allowlist_matches_configured_tool_argument() {
3693 let policy = test_policy();
3694 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
3695 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
3696 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
3697 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
3698 }
3699
3700 #[tokio::test]
3701 async fn array_arg_with_matching_allowlist_is_denied() {
3702 let policy = Arc::new(test_policy());
3703 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3704 let body = tool_call_body(
3705 "resource_exec",
3706 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
3707 );
3708 let req = Request::builder()
3709 .method(Method::POST)
3710 .uri("/mcp")
3711 .header("content-type", "application/json")
3712 .body(Body::from(body))
3713 .unwrap();
3714 let resp = app.oneshot(req).await.unwrap();
3715 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3716 }
3717
3718 #[tokio::test]
3719 async fn object_arg_with_matching_allowlist_is_denied() {
3720 let policy = Arc::new(test_policy());
3721 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3722 let body = tool_call_body(
3723 "resource_exec",
3724 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
3725 );
3726 let req = Request::builder()
3727 .method(Method::POST)
3728 .uri("/mcp")
3729 .header("content-type", "application/json")
3730 .body(Body::from(body))
3731 .unwrap();
3732 let resp = app.oneshot(req).await.unwrap();
3733 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3734 }
3735
3736 #[tokio::test]
3737 async fn number_arg_with_matching_allowlist_is_denied() {
3738 let policy = Arc::new(test_policy());
3739 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3740 let body = tool_call_body(
3741 "resource_exec",
3742 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
3743 );
3744 let req = Request::builder()
3745 .method(Method::POST)
3746 .uri("/mcp")
3747 .header("content-type", "application/json")
3748 .body(Body::from(body))
3749 .unwrap();
3750 let resp = app.oneshot(req).await.unwrap();
3751 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3752 }
3753
3754 #[tokio::test]
3755 async fn bool_arg_with_matching_allowlist_is_denied() {
3756 let policy = Arc::new(test_policy());
3757 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3758 let body = tool_call_body(
3759 "resource_exec",
3760 &serde_json::json!({ "host": "dev-1", "cmd": true }),
3761 );
3762 let req = Request::builder()
3763 .method(Method::POST)
3764 .uri("/mcp")
3765 .header("content-type", "application/json")
3766 .body(Body::from(body))
3767 .unwrap();
3768 let resp = app.oneshot(req).await.unwrap();
3769 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3770 }
3771
3772 #[tokio::test]
3773 async fn null_arg_with_matching_allowlist_is_denied() {
3774 let policy = Arc::new(test_policy());
3775 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3776 let body = tool_call_body(
3777 "resource_exec",
3778 &serde_json::json!({ "host": "dev-1", "cmd": null }),
3779 );
3780 let req = Request::builder()
3781 .method(Method::POST)
3782 .uri("/mcp")
3783 .header("content-type", "application/json")
3784 .body(Body::from(body))
3785 .unwrap();
3786 let resp = app.oneshot(req).await.unwrap();
3787 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3788 }
3789
3790 #[tokio::test]
3791 async fn non_string_arg_without_allowlist_is_passthrough() {
3792 let policy = Arc::new(test_policy());
3796 let id = AuthIdentity {
3797 method: crate::auth::AuthMethod::BearerToken,
3798 name: "olivia".into(),
3799 role: "ops".into(),
3800 raw_token: None,
3801 sub: None,
3802 };
3803 let app = rbac_router_with_identity(policy, id);
3804 let body = tool_call_body(
3805 "resource_exec",
3806 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
3807 );
3808 let req = Request::builder()
3809 .method(Method::POST)
3810 .uri("/mcp")
3811 .header("content-type", "application/json")
3812 .body(Body::from(body))
3813 .unwrap();
3814 let resp = app.oneshot(req).await.unwrap();
3815 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3816 }
3817
3818 #[tokio::test]
3819 async fn string_arg_in_allowlist_still_passes() {
3820 let policy = Arc::new(test_policy());
3821 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3822 let body = tool_call_body(
3823 "resource_exec",
3824 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
3825 );
3826 let req = Request::builder()
3827 .method(Method::POST)
3828 .uri("/mcp")
3829 .header("content-type", "application/json")
3830 .body(Body::from(body))
3831 .unwrap();
3832 let resp = app.oneshot(req).await.unwrap();
3833 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3834 }
3835
3836 async fn exec_status(args: &serde_json::Value) -> StatusCode {
3845 let policy = Arc::new(test_policy());
3846 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3847 let body = tool_call_body("resource_exec", args);
3848 let req = Request::builder()
3849 .method(Method::POST)
3850 .uri("/mcp")
3851 .header("content-type", "application/json")
3852 .body(Body::from(body))
3853 .unwrap();
3854 app.oneshot(req).await.unwrap().status()
3855 }
3856
3857 #[tokio::test]
3858 async fn non_string_host_is_denied_for_every_json_type() {
3859 for host in [
3860 serde_json::json!(["prod-1"]),
3861 serde_json::json!({ "name": "prod-1" }),
3862 serde_json::json!(42),
3863 serde_json::json!(true),
3864 serde_json::json!(null),
3865 ] {
3866 let args = serde_json::json!({ "host": host, "cmd": "sh" });
3867 assert_eq!(
3868 exec_status(&args).await,
3869 StatusCode::FORBIDDEN,
3870 "non-string host must not bypass host globs: {host:?}"
3871 );
3872 }
3873 }
3874
3875 #[tokio::test]
3876 async fn string_host_outside_globs_still_denied() {
3877 let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
3878 assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
3879 }
3880
3881 #[tokio::test]
3882 async fn string_host_inside_globs_still_allowed() {
3883 let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
3884 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3885 }
3886
3887 #[tokio::test]
3891 async fn absent_host_still_routes_to_check_operation() {
3892 let args = serde_json::json!({ "cmd": "sh" });
3893 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3894 }
3895
3896 fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
3905 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
3906 .with_argument_allowlists(vec![
3907 ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
3908 ]);
3909 let mut config = RbacConfig::with_roles(vec![role]);
3910 config.enabled = true;
3911 RbacPolicy::new(&config)
3912 }
3913
3914 fn viewer_identity() -> AuthIdentity {
3915 AuthIdentity {
3916 method: crate::auth::AuthMethod::BearerToken,
3917 name: "viewer-1".into(),
3918 role: "viewer".into(),
3919 raw_token: None,
3920 sub: None,
3921 }
3922 }
3923
3924 async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
3925 let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
3926 let body = serde_json::json!({
3927 "jsonrpc": "2.0",
3928 "id": 1,
3929 "method": "tools/call",
3930 "params": params
3931 })
3932 .to_string();
3933 let req = Request::builder()
3934 .method(Method::POST)
3935 .uri("/mcp")
3936 .header("content-type", "application/json")
3937 .body(Body::from(body))
3938 .unwrap();
3939 app.oneshot(req).await.unwrap().status()
3940 }
3941
3942 #[tokio::test]
3943 async fn required_false_still_allows_omitting_the_argument() {
3944 let params = serde_json::json!({ "name": "run", "arguments": {} });
3945 assert_ne!(
3946 run_status(required_policy(vec!["ls".into()], false), ¶ms).await,
3947 StatusCode::FORBIDDEN,
3948 "default behaviour must be unchanged"
3949 );
3950 }
3951
3952 #[tokio::test]
3953 async fn required_true_denies_omitted_argument() {
3954 let params = serde_json::json!({ "name": "run", "arguments": {} });
3955 assert_eq!(
3956 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3957 StatusCode::FORBIDDEN
3958 );
3959 }
3960
3961 #[tokio::test]
3962 async fn required_true_allows_permitted_value() {
3963 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
3964 assert_ne!(
3965 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3966 StatusCode::FORBIDDEN
3967 );
3968 }
3969
3970 #[tokio::test]
3971 async fn required_true_still_denies_disallowed_value() {
3972 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
3973 assert_eq!(
3974 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3975 StatusCode::FORBIDDEN
3976 );
3977 }
3978
3979 #[tokio::test]
3980 async fn required_true_denies_non_string_value() {
3981 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
3982 assert_eq!(
3983 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3984 StatusCode::FORBIDDEN
3985 );
3986 }
3987
3988 #[tokio::test]
3989 async fn required_true_denies_absent_or_non_object_arguments() {
3990 for params in [
3991 serde_json::json!({ "name": "run" }),
3992 serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
3993 serde_json::json!({ "name": "run", "arguments": null }),
3994 ] {
3995 assert_eq!(
3996 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3997 StatusCode::FORBIDDEN,
3998 "omitting the arguments object must not skip `required`: {params:?}"
3999 );
4000 }
4001 }
4002
4003 #[tokio::test]
4006 async fn required_true_with_empty_allowed_accepts_any_string() {
4007 let params =
4008 serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
4009 assert_ne!(
4010 run_status(required_policy(vec![], true), ¶ms).await,
4011 StatusCode::FORBIDDEN
4012 );
4013 }
4014
4015 #[tokio::test]
4016 async fn required_true_with_empty_allowed_denies_omitted_argument() {
4017 let params = serde_json::json!({ "name": "run", "arguments": {} });
4018 assert_eq!(
4019 run_status(required_policy(vec![], true), ¶ms).await,
4020 StatusCode::FORBIDDEN
4021 );
4022 }
4023
4024 #[tokio::test]
4025 async fn required_true_with_empty_allowed_denies_non_string() {
4026 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
4027 assert_eq!(
4028 run_status(required_policy(vec![], true), ¶ms).await,
4029 StatusCode::FORBIDDEN
4030 );
4031 }
4032
4033 #[tokio::test]
4034 async fn required_honours_globbed_tool_patterns() {
4035 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
4036 .with_argument_allowlists(vec![
4037 ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
4038 ]);
4039 let mut config = RbacConfig::with_roles(vec![role]);
4040 config.enabled = true;
4041 let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
4042 assert_eq!(
4043 run_status(RbacPolicy::new(&config), ¶ms).await,
4044 StatusCode::FORBIDDEN,
4045 "a globbed tool pattern must enforce presence, not just value"
4046 );
4047 }
4048
4049 #[test]
4050 fn required_defaults_to_false_when_absent_from_toml() {
4051 let cfg: RbacConfig = toml::from_str(
4052 r#"
4053 enabled = true
4054 [[roles]]
4055 name = "viewer"
4056 allow = ["run"]
4057 [[roles.argument_allowlists]]
4058 tool = "run"
4059 argument = "cmd"
4060 allowed = ["ls"]
4061 "#,
4062 )
4063 .expect("config without `required` must still deserialize");
4064 assert!(
4065 !cfg.roles[0].argument_allowlists[0].required,
4066 "omitted `required` must default to false so existing configs are unchanged"
4067 );
4068 }
4069
4070 #[test]
4071 fn unknown_rbac_config_key_is_rejected() {
4072 let err = toml::from_str::<RbacConfig>(
4073 "
4074 enabled = true
4075 typo_roles = []
4076 ",
4077 )
4078 .unwrap_err();
4079
4080 let msg = err.to_string();
4081 assert!(
4082 msg.contains("typo_roles"),
4083 "error must name the offending key: {msg}"
4084 );
4085 }
4086}