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)]
345#[serde(deny_unknown_fields)]
346#[non_exhaustive]
347pub struct ArgumentAllowlist {
348 pub tool: String,
350 pub argument: String,
352 #[serde(default)]
354 pub allowed: Vec<String>,
355 #[serde(default)]
368 pub required: bool,
369 #[serde(default)]
383 pub deny_unknown_arguments: bool,
384}
385
386impl ArgumentAllowlist {
387 #[must_use]
393 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
394 Self {
395 tool: tool.into(),
396 argument: argument.into(),
397 allowed,
398 required: false,
399 deny_unknown_arguments: false,
400 }
401 }
402
403 #[must_use]
408 pub fn new_required(
409 tool: impl Into<String>,
410 argument: impl Into<String>,
411 allowed: Vec<String>,
412 ) -> Self {
413 Self::new(tool, argument, allowed).with_required(true)
414 }
415
416 #[must_use]
418 pub const fn with_required(mut self, required: bool) -> Self {
419 self.required = required;
420 self
421 }
422
423 #[must_use]
428 pub const fn with_deny_unknown_arguments(mut self, deny: bool) -> Self {
429 self.deny_unknown_arguments = deny;
430 self
431 }
432}
433
434fn default_hosts() -> Vec<String> {
435 vec!["*".into()]
436}
437
438#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
447#[serde(rename_all = "kebab-case")]
448#[non_exhaustive]
449pub enum AllowOperationMatching {
450 #[default]
456 Legacy,
457 Glob,
462}
463
464#[derive(Debug, Clone, Default, Deserialize)]
466#[serde(deny_unknown_fields)]
467#[non_exhaustive]
468pub struct RbacConfig {
469 #[serde(default)]
471 pub enabled: bool,
472 #[serde(default)]
474 pub roles: Vec<RoleConfig>,
475 #[serde(default)]
479 pub allow_operation_matching: AllowOperationMatching,
480 #[serde(default)]
495 pub global_deny: Vec<String>,
496 #[serde(default)]
505 pub redaction_salt: Option<SecretString>,
506}
507
508impl RbacConfig {
509 #[must_use]
511 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
512 Self {
513 enabled: true,
514 roles,
515 allow_operation_matching: AllowOperationMatching::default(),
516 global_deny: Vec::new(),
517 redaction_salt: None,
518 }
519 }
520
521 #[must_use]
523 pub fn with_global_deny(mut self, global_deny: Vec<String>) -> Self {
524 self.global_deny = global_deny;
525 self
526 }
527
528 #[must_use]
530 pub fn with_allow_operation_matching(mut self, mode: AllowOperationMatching) -> Self {
531 self.allow_operation_matching = mode;
532 self
533 }
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538#[non_exhaustive]
539pub enum RbacDecision {
540 Allow,
542 Deny,
544}
545
546#[derive(Debug, Clone, serde::Serialize)]
548#[non_exhaustive]
549pub struct RbacRoleSummary {
550 pub name: String,
552 pub allow: usize,
554 pub deny: usize,
556 pub hosts: usize,
558 pub argument_allowlists: usize,
560}
561
562#[derive(Debug, Clone, serde::Serialize)]
564#[non_exhaustive]
565pub struct RbacPolicySummary {
566 pub enabled: bool,
568 pub global_deny: usize,
570 pub roles: Vec<RbacRoleSummary>,
572}
573
574#[derive(Debug, Clone)]
580#[non_exhaustive]
581pub struct RbacPolicy {
582 roles: Vec<RoleConfig>,
583 enabled: bool,
584 allow_operation_matching: AllowOperationMatching,
585 global_deny: Vec<String>,
586 redaction_salt: Arc<SecretString>,
589}
590
591impl RbacPolicy {
592 #[must_use]
595 pub fn new(config: &RbacConfig) -> Self {
596 warn_on_optional_value_allowlists(&config.roles);
597 warn_on_literal_allow_globs(&config.roles, config.allow_operation_matching);
598 warn_on_inert_global_deny(config);
599 let salt = config
600 .redaction_salt
601 .clone()
602 .unwrap_or_else(|| process_redaction_salt().clone());
603 Self {
604 roles: config.roles.clone(),
605 enabled: config.enabled,
606 allow_operation_matching: config.allow_operation_matching,
607 global_deny: config.global_deny.clone(),
608 redaction_salt: Arc::new(salt),
609 }
610 }
611
612 #[must_use]
614 pub fn disabled() -> Self {
615 Self {
616 roles: Vec::new(),
617 enabled: false,
618 allow_operation_matching: AllowOperationMatching::default(),
619 global_deny: Vec::new(),
620 redaction_salt: Arc::new(process_redaction_salt().clone()),
621 }
622 }
623
624 #[must_use]
626 pub fn is_enabled(&self) -> bool {
627 self.enabled
628 }
629
630 #[must_use]
635 pub fn summary(&self) -> RbacPolicySummary {
636 let roles = self
637 .roles
638 .iter()
639 .map(|r| RbacRoleSummary {
640 name: r.name.clone(),
641 allow: r.allow.len(),
642 deny: r.deny.len(),
643 hosts: r.hosts.len(),
644 argument_allowlists: r.argument_allowlists.len(),
645 })
646 .collect();
647 RbacPolicySummary {
648 enabled: self.enabled,
649 global_deny: self.global_deny.len(),
650 roles,
651 }
652 }
653
654 fn global_denied(&self, operation: &str) -> bool {
659 self.global_deny.iter().any(|d| glob_match(d, operation))
660 }
661
662 fn role_denies(role_cfg: &RoleConfig, operation: &str) -> bool {
669 role_cfg.deny.iter().any(|d| glob_match(d, operation))
670 }
671
672 fn role_allows(&self, role_cfg: &RoleConfig, operation: &str) -> bool {
674 role_cfg.allow.iter().any(|a| {
675 a == "*"
676 || match self.allow_operation_matching {
677 AllowOperationMatching::Legacy => a == operation,
678 AllowOperationMatching::Glob => glob_match(a, operation),
679 }
680 })
681 }
682
683 #[must_use]
688 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
689 if !self.enabled {
690 return RbacDecision::Allow;
691 }
692 if self.global_denied(operation) {
693 return RbacDecision::Deny;
694 }
695 let Some(role_cfg) = self.find_role(role) else {
696 return RbacDecision::Deny;
697 };
698 if Self::role_denies(role_cfg, operation) {
699 return RbacDecision::Deny;
700 }
701 if self.role_allows(role_cfg, operation) {
702 return RbacDecision::Allow;
703 }
704 RbacDecision::Deny
705 }
706
707 #[must_use]
717 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
718 if !self.enabled {
719 return RbacDecision::Allow;
720 }
721 if self.global_denied(operation) {
722 return RbacDecision::Deny;
723 }
724 let Some(role_cfg) = self.find_role(role) else {
725 return RbacDecision::Deny;
726 };
727 if Self::role_denies(role_cfg, operation) {
728 return RbacDecision::Deny;
729 }
730 if !self.role_allows(role_cfg, operation) {
731 return RbacDecision::Deny;
732 }
733 if !Self::host_matches(&role_cfg.hosts, host) {
734 return RbacDecision::Deny;
735 }
736 RbacDecision::Allow
737 }
738
739 #[must_use]
743 pub fn host_visible(&self, role: &str, host: &str) -> bool {
744 if !self.enabled {
745 return true;
746 }
747 let Some(role_cfg) = self.find_role(role) else {
748 return false;
749 };
750 Self::host_matches(&role_cfg.hosts, host)
751 }
752
753 #[must_use]
755 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
756 self.find_role(role).map(|r| r.hosts.as_slice())
757 }
758
759 #[must_use]
798 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
799 if !self.enabled {
800 return true;
801 }
802 let Some(role_cfg) = self.find_role(role) else {
803 return false;
804 };
805 for al in &role_cfg.argument_allowlists {
806 if al.tool != tool && !glob_match(&al.tool, tool) {
807 continue;
808 }
809 if al.argument != argument {
810 continue;
811 }
812 if al.allowed.is_empty() {
813 continue;
814 }
815 let Some(tokens) = shlex::split(value) else {
820 return false;
821 };
822 let Some(first_token) = tokens.first() else {
823 return false;
824 };
825 if first_token.is_empty() {
829 return false;
830 }
831 let basename = first_token
835 .rsplit('/')
836 .next()
837 .unwrap_or(first_token.as_str());
838 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
839 return false;
840 }
841 }
842 true
843 }
844
845 #[must_use]
855 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
856 if !self.enabled {
857 return false;
858 }
859 let Some(role_cfg) = self.find_role(role) else {
860 return false;
861 };
862 role_cfg.argument_allowlists.iter().any(|al| {
863 (al.tool == tool || glob_match(&al.tool, tool))
864 && al.argument == argument
865 && !al.allowed.is_empty()
866 })
867 }
868
869 fn strict_argument_names(&self, role: &str, tool: &str) -> Option<Vec<&str>> {
879 if !self.enabled {
880 return None;
881 }
882 let role_cfg = self.find_role(role)?;
883 let matching = || {
884 role_cfg
885 .argument_allowlists
886 .iter()
887 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
888 };
889 if !matching().any(|al| al.deny_unknown_arguments) {
890 return None;
891 }
892 Some(matching().map(|al| al.argument.as_str()).collect())
893 }
894
895 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
897 self.roles.iter().find(|r| r.name == name)
898 }
899
900 fn missing_required_argument(
911 &self,
912 role: &str,
913 tool: &str,
914 args: Option<&serde_json::Map<String, serde_json::Value>>,
915 ) -> Option<&str> {
916 if !self.enabled {
917 return None;
918 }
919 let role_cfg = self.find_role(role)?;
920 role_cfg
921 .argument_allowlists
922 .iter()
923 .filter(|al| al.required)
924 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
928 .find(|al| {
929 !args.is_some_and(|a| {
930 a.get(&al.argument)
931 .is_some_and(serde_json::Value::is_string)
932 })
933 })
934 .map(|al| al.argument.as_str())
935 }
936
937 fn host_matches(patterns: &[String], host: &str) -> bool {
957 let host_lower = patterns
961 .iter()
962 .any(|p| p.contains('*'))
963 .then(|| host.to_ascii_lowercase());
964 patterns.iter().any(|p| {
965 if p.contains('*') {
966 host_lower
967 .as_deref()
968 .is_some_and(|h| glob_match(&p.to_ascii_lowercase(), h))
969 } else {
970 p.eq_ignore_ascii_case(host)
971 }
972 })
973 }
974
975 #[must_use]
984 pub fn redact_arg(&self, value: &str) -> String {
985 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
986 }
987}
988
989fn warn_on_literal_allow_globs(roles: &[RoleConfig], mode: AllowOperationMatching) {
997 match mode {
998 AllowOperationMatching::Glob => return,
999 AllowOperationMatching::Legacy => {}
1000 }
1001 for role in roles {
1002 for entry in role.allow.iter().filter(|a| *a != "*" && a.contains('*')) {
1003 tracing::warn!(
1004 role = %role.name,
1005 operation = %entry,
1006 "allow entry contains '*' but operation matching is 'legacy'; \
1007 the '*' is matched literally, not as a pattern -- set \
1008 rbac.allow_operation_matching = \"glob\" to enable globbing, \
1009 or list the operation names exactly"
1010 );
1011 }
1012 }
1013}
1014
1015fn warn_on_inert_global_deny(config: &RbacConfig) {
1017 if !config.enabled && !config.global_deny.is_empty() {
1018 tracing::warn!(
1019 patterns = config.global_deny.len(),
1020 "rbac.global_deny is configured but rbac.enabled is false; \
1021 the kill switch is inert because all checks short-circuit to allow"
1022 );
1023 }
1024}
1025
1026fn warn_on_optional_value_allowlists(roles: &[RoleConfig]) {
1027 for role in roles {
1028 for allowlist in &role.argument_allowlists {
1029 if !allowlist.allowed.is_empty() && !allowlist.required {
1030 tracing::warn!(
1031 role = %role.name,
1032 tool = %allowlist.tool,
1033 argument = %allowlist.argument,
1034 "optional argument allowlist may fail open"
1035 );
1036 }
1037 }
1038 }
1039}
1040
1041fn process_redaction_salt() -> &'static SecretString {
1044 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
1045 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
1046 PROCESS_SALT.get_or_init(|| {
1047 let mut bytes = [0u8; 32];
1048 rand::fill(&mut bytes);
1049 SecretString::from(STANDARD_NO_PAD.encode(bytes))
1052 })
1053}
1054
1055fn redact_with_salt(salt: &[u8], value: &str) -> String {
1060 use std::fmt::Write as _;
1061
1062 use sha2::Digest as _;
1063
1064 type HmacSha256 = Hmac<Sha256>;
1065 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
1071 m
1072 } else {
1073 let digest = Sha256::digest(salt);
1074 #[allow(
1075 clippy::expect_used,
1076 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
1077 )]
1078 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
1079 };
1080 mac.update(value.as_bytes());
1081 let bytes = mac.finalize().into_bytes();
1082 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
1084 let mut out = String::with_capacity(8);
1085 for b in prefix {
1086 let _ = write!(out, "{b:02x}");
1087 }
1088 out
1089}
1090
1091#[allow(
1112 clippy::too_many_lines,
1113 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
1114)]
1115pub(crate) async fn rbac_middleware(
1119 policy: Arc<RbacPolicy>,
1120 tool_limiter: Option<Arc<ToolRateLimiter>>,
1121 req: Request<Body>,
1122 next: Next,
1123) -> Response {
1124 if req.method() != Method::POST {
1126 return next.run(req).await;
1127 }
1128
1129 let peer_key = tool_limiter
1135 .is_some()
1136 .then(|| crate::transport::limiter_client_key(req.extensions()));
1137
1138 let identity = req.extensions().get::<AuthIdentity>();
1140 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
1141 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
1142 let raw_token: SecretString = identity
1145 .and_then(|id| id.raw_token.clone())
1146 .unwrap_or_else(|| SecretString::from(String::new()));
1147 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
1148
1149 if policy.is_enabled() && identity.is_none() {
1151 return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
1152 }
1153
1154 let (parts, body) = req.into_parts();
1156 let bytes = match body.collect().await {
1157 Ok(collected) => collected.to_bytes(),
1158 Err(e) => {
1159 tracing::error!(error = %e, "failed to read request body");
1160 return (
1161 StatusCode::INTERNAL_SERVER_ERROR,
1162 "failed to read request body",
1163 )
1164 .into_response();
1165 }
1166 };
1167
1168 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
1170 let tool_calls = extract_tool_calls(&json);
1171 if !tool_calls.is_empty() {
1172 for params in tool_calls {
1173 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_key.as_ref()) {
1174 #[cfg(feature = "metrics")]
1175 crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
1176 return resp;
1177 }
1178 if policy.is_enabled()
1179 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
1180 {
1181 return resp;
1182 }
1183 }
1184 }
1185 }
1186 let req = Request::from_parts(parts, Body::from(bytes));
1190
1191 if role.is_empty() {
1193 next.run(req).await
1194 } else {
1195 CURRENT_ROLE
1196 .scope(
1197 role,
1198 CURRENT_IDENTITY.scope(
1199 identity_name,
1200 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
1201 ),
1202 )
1203 .await
1204 }
1205}
1206
1207fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
1213 match value {
1214 serde_json::Value::Object(map) => map
1215 .get("method")
1216 .and_then(serde_json::Value::as_str)
1217 .filter(|method| *method == "tools/call")
1218 .and_then(|_| map.get("params"))
1219 .into_iter()
1220 .collect(),
1221 serde_json::Value::Array(items) => items
1222 .iter()
1223 .filter_map(|item| match item {
1224 serde_json::Value::Object(map) => map
1225 .get("method")
1226 .and_then(serde_json::Value::as_str)
1227 .filter(|method| *method == "tools/call")
1228 .and_then(|_| map.get("params")),
1229 serde_json::Value::Null
1230 | serde_json::Value::Bool(_)
1231 | serde_json::Value::Number(_)
1232 | serde_json::Value::String(_)
1233 | serde_json::Value::Array(_) => None,
1234 })
1235 .collect(),
1236 serde_json::Value::Null
1237 | serde_json::Value::Bool(_)
1238 | serde_json::Value::Number(_)
1239 | serde_json::Value::String(_) => Vec::new(),
1240 }
1241}
1242
1243fn enforce_rate_limit(
1246 tool_limiter: Option<&ToolRateLimiter>,
1247 peer_key: Option<&crate::transport::RateLimitKey>,
1248) -> Option<Response> {
1249 let limiter = tool_limiter?;
1250 let key = peer_key?;
1251 match limiter.check_key_detailed(key) {
1252 Ok(()) => None,
1253 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1254 tracing::warn!(rate_limit_key = %key, "tool invocation rate limited");
1255 Some(
1256 RmcpServerKitError::RateLimitedFor {
1257 message: "too many tool invocations".into(),
1258 retry_after: wait,
1259 }
1260 .into_response(),
1261 )
1262 }
1263 Err(BoundedLimiterDeny::CapacityFull) => {
1264 tracing::warn!(
1265 rate_limit_key = %key,
1266 "tool invocation limiter rejected unseen key because tracked-key capacity is full"
1267 );
1268 Some(
1269 (
1270 StatusCode::SERVICE_UNAVAILABLE,
1271 "rate limiter capacity exhausted",
1272 )
1273 .into_response(),
1274 )
1275 }
1276 }
1277}
1278
1279fn enforce_tool_policy(
1288 policy: &RbacPolicy,
1289 identity_name: &str,
1290 role: &str,
1291 params: &serde_json::Value,
1292) -> Option<Response> {
1293 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
1294 let host_value = params.get("arguments").and_then(|a| a.get("host"));
1295
1296 if let Some(value) = host_value
1304 && !value.is_string()
1305 {
1306 tracing::warn!(
1307 user = %identity_name,
1308 role = %role,
1309 tool = tool_name,
1310 value_type = json_value_type(value),
1311 "non-string host argument rejected"
1312 );
1313 return Some(
1314 RmcpServerKitError::Rbac(format!(
1315 "argument 'host' must be a string for tool '{tool_name}'"
1316 ))
1317 .into_response(),
1318 );
1319 }
1320 let host = host_value.and_then(|h| h.as_str());
1323
1324 let decision = if let Some(host) = host {
1325 policy.check(role, tool_name, host)
1326 } else {
1327 policy.check_operation(role, tool_name)
1328 };
1329 if decision == RbacDecision::Deny {
1330 tracing::warn!(
1331 user = %identity_name,
1332 role = %role,
1333 tool = tool_name,
1334 host = host.unwrap_or("-"),
1335 "RBAC denied"
1336 );
1337 return Some(
1338 RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
1339 .into_response(),
1340 );
1341 }
1342
1343 let args = params.get("arguments").and_then(|a| a.as_object());
1344 let strict = policy.strict_argument_names(role, tool_name);
1345 if let Some(args) = args {
1346 for (arg_key, arg_val) in args {
1347 if let Some(ref permitted) = strict
1348 && let Some(resp) = check_strict_argument(
1349 identity_name,
1350 role,
1351 tool_name,
1352 permitted,
1353 arg_key,
1354 arg_val,
1355 )
1356 {
1357 return Some(resp);
1358 }
1359 if let Some(resp) =
1360 check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
1361 {
1362 return Some(resp);
1363 }
1364 }
1365 }
1366 check_required_arguments(policy, identity_name, role, tool_name, args)
1367}
1368
1369fn check_strict_argument(
1374 identity_name: &str,
1375 role: &str,
1376 tool_name: &str,
1377 permitted: &[&str],
1378 arg_key: &str,
1379 arg_val: &serde_json::Value,
1380) -> Option<Response> {
1381 if !permitted.contains(&arg_key) {
1382 tracing::warn!(
1383 user = %identity_name,
1384 role = %role,
1385 tool = tool_name,
1386 argument = arg_key,
1387 "unknown argument rejected by strict allowlist"
1388 );
1389 return Some(
1390 RmcpServerKitError::Rbac(format!(
1391 "argument '{arg_key}' is not permitted for tool '{tool_name}'"
1392 ))
1393 .into_response(),
1394 );
1395 }
1396 if arg_val.is_object() || arg_val.is_array() {
1397 tracing::warn!(
1398 user = %identity_name,
1399 role = %role,
1400 tool = tool_name,
1401 argument = arg_key,
1402 value_type = json_value_type(arg_val),
1403 "structured argument rejected by strict allowlist"
1404 );
1405 return Some(
1406 RmcpServerKitError::Rbac(format!(
1407 "argument '{arg_key}' must not be an object or array for tool '{tool_name}'"
1408 ))
1409 .into_response(),
1410 );
1411 }
1412 None
1413}
1414
1415fn check_required_arguments(
1423 policy: &RbacPolicy,
1424 identity_name: &str,
1425 role: &str,
1426 tool_name: &str,
1427 args: Option<&serde_json::Map<String, serde_json::Value>>,
1428) -> Option<Response> {
1429 let missing = policy.missing_required_argument(role, tool_name, args)?;
1430 tracing::warn!(
1431 user = %identity_name,
1432 role = %role,
1433 tool = tool_name,
1434 argument = missing,
1435 "required argument missing"
1436 );
1437 Some(
1438 RmcpServerKitError::Rbac(format!(
1439 "argument '{missing}' is required for tool '{tool_name}'"
1440 ))
1441 .into_response(),
1442 )
1443}
1444
1445fn check_argument(
1446 policy: &RbacPolicy,
1447 identity_name: &str,
1448 role: &str,
1449 tool_name: &str,
1450 arg_key: &str,
1451 arg_val: &serde_json::Value,
1452) -> Option<Response> {
1453 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1454 return None;
1455 }
1456 let Some(val_str) = arg_val.as_str() else {
1457 tracing::warn!(
1463 user = %identity_name,
1464 role = %role,
1465 tool = tool_name,
1466 argument = arg_key,
1467 value_type = json_value_type(arg_val),
1468 "non-string argument rejected by allowlist"
1469 );
1470 return Some(
1471 RmcpServerKitError::Rbac(format!(
1472 "argument '{arg_key}' must be a string for tool '{tool_name}'"
1473 ))
1474 .into_response(),
1475 );
1476 };
1477 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1478 return None;
1479 }
1480 tracing::warn!(
1485 user = %identity_name,
1486 role = %role,
1487 tool = tool_name,
1488 argument = arg_key,
1489 arg_hmac = %policy.redact_arg(val_str),
1490 "argument not in allowlist"
1491 );
1492 Some(
1493 RmcpServerKitError::Rbac(format!(
1494 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1495 ))
1496 .into_response(),
1497 )
1498}
1499
1500fn json_value_type(v: &serde_json::Value) -> &'static str {
1501 match v {
1502 serde_json::Value::Null => "null",
1503 serde_json::Value::Bool(_) => "bool",
1504 serde_json::Value::Number(_) => "number",
1505 serde_json::Value::String(_) => "string",
1506 serde_json::Value::Array(_) => "array",
1507 serde_json::Value::Object(_) => "object",
1508 }
1509}
1510
1511fn glob_match(pattern: &str, text: &str) -> bool {
1521 let parts: Vec<&str> = pattern.split('*').collect();
1522 if parts.len() == 1 {
1523 return pattern == text;
1525 }
1526
1527 let pos = if let Some(&first) = parts.first()
1529 && !first.is_empty()
1530 {
1531 if !text.starts_with(first) {
1532 return false;
1533 }
1534 first.len()
1535 } else {
1536 0
1537 };
1538
1539 if let Some(&last) = parts.last()
1541 && !last.is_empty()
1542 {
1543 if !text.get(pos..).unwrap_or_default().ends_with(last) {
1544 return false;
1545 }
1546 let end = text.len() - last.len();
1548 if pos > end {
1549 return false;
1550 }
1551 let middle = text.get(pos..end).unwrap_or_default();
1553 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1554 return match_middle(middle, middle_parts);
1555 }
1556
1557 let middle = text.get(pos..).unwrap_or_default();
1559 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1560 match_middle(middle, middle_parts)
1561}
1562
1563fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1565 for part in parts {
1566 if part.is_empty() {
1567 continue;
1568 }
1569 if let Some(idx) = text.find(part) {
1570 text = text.get(idx + part.len()..).unwrap_or_default();
1571 } else {
1572 return false;
1573 }
1574 }
1575 true
1576}
1577
1578impl RbacConfig {
1579 pub fn apply_env_overrides(
1612 &mut self,
1613 ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1614 let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1615 let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1616 match (direct, file) {
1617 (None, None) => Ok(Vec::new()),
1618 (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1619 "{} and {} must not both be set",
1620 crate::config::RBAC_REDACTION_SALT_ENV,
1621 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1622 ))),
1623 (Some(value), None) => {
1624 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1625 self.redaction_salt = Some(SecretString::from(value));
1626 Ok(vec![crate::config::secret_env_report(
1627 crate::config::RBAC_REDACTION_SALT_ENV,
1628 "rbac.redaction_salt",
1629 crate::config::EnvOverrideSource::Env,
1630 )])
1631 }
1632 (None, Some(path)) => {
1633 let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1634 RmcpServerKitError::Config(format!(
1635 "failed to read {} file {path:?}: {error}",
1636 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1637 ))
1638 })?;
1639 let secret = crate::config::normalize_text_secret_file(secret);
1640 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1641 self.redaction_salt = Some(SecretString::from(secret));
1642 Ok(vec![crate::config::secret_env_report(
1643 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1644 "rbac.redaction_salt",
1645 crate::config::EnvOverrideSource::File,
1646 )])
1647 }
1648 }
1649 }
1650}
1651
1652fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1653 if value.trim().is_empty() {
1654 return Err(RmcpServerKitError::Config(format!(
1655 "{env_var} must not be empty or whitespace-only"
1656 )));
1657 }
1658 Ok(())
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use std::net::IpAddr;
1664
1665 use super::*;
1666 use crate::transport::RateLimitKey;
1667
1668 fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1669 temp_env::with_vars(
1670 [
1671 (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1672 (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1673 ]
1674 .into_iter()
1675 .chain(vars.iter().copied())
1676 .collect::<Vec<_>>(),
1677 f,
1678 )
1679 }
1680
1681 #[test]
1682 fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1683 with_rbac_env(
1684 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1685 || {
1686 let mut cfg = RbacConfig::default();
1687 let report = cfg.apply_env_overrides().unwrap();
1688 assert!(cfg.redaction_salt.is_some());
1689 assert_eq!(report.len(), 1);
1690 assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1691 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1692 assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1693 assert!(report[0].value.is_none());
1694 assert!(!format!("{report:?}").contains("s3cret"));
1695 },
1696 );
1697 }
1698
1699 #[test]
1700 fn e7_redaction_salt_value_and_file_conflict_fails() {
1701 with_rbac_env(
1702 &[
1703 (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1704 (
1705 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1706 Some("/tmp/secret-file"),
1707 ),
1708 ],
1709 || {
1710 let mut cfg = RbacConfig::default();
1711 let err = cfg.apply_env_overrides().unwrap_err();
1712 let msg = err.to_string();
1713 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1714 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1715 },
1716 );
1717 }
1718
1719 #[test]
1720 fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1721 let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1722 let direct_redaction = redaction_from_direct_salt("same-salt");
1723
1724 assert_eq!(file_redaction, direct_redaction);
1725 assert_eq!(report.len(), 1);
1726 assert_eq!(
1727 report[0].env_var,
1728 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1729 );
1730 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1731 assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1732 assert!(report[0].value.is_none());
1733 }
1734
1735 #[test]
1736 fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1737 let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1738 assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1739
1740 let (spaced_redaction, _) = redaction_from_file(" same-salt \n").expect("spaced salt");
1741 assert_eq!(
1742 spaced_redaction,
1743 redaction_from_direct_salt(" same-salt ")
1744 );
1745 assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1746 }
1747
1748 #[derive(Clone, Default)]
1749 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
1750
1751 impl CapturedLogs {
1752 fn contents(&self) -> String {
1753 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
1754 String::from_utf8(bytes).unwrap_or_default()
1755 }
1756 }
1757
1758 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
1759
1760 impl std::io::Write for CapturedLogsWriter {
1761 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1762 if let Ok(mut guard) = self.0.lock() {
1763 guard.extend_from_slice(buf);
1764 }
1765 Ok(buf.len())
1766 }
1767
1768 fn flush(&mut self) -> std::io::Result<()> {
1769 Ok(())
1770 }
1771 }
1772
1773 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
1774 type Writer = CapturedLogsWriter;
1775
1776 fn make_writer(&'a self) -> Self::Writer {
1777 CapturedLogsWriter(Arc::clone(&self.0))
1778 }
1779 }
1780
1781 fn allowlist_warning_policy(allowlist: ArgumentAllowlist) -> RbacConfig {
1782 RbacConfig::with_roles(vec![
1783 RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1784 .with_argument_allowlists(vec![allowlist]),
1785 ])
1786 }
1787
1788 fn capture_policy_construction_logs(config: &RbacConfig) -> String {
1789 let logs = CapturedLogs::default();
1790 let subscriber = tracing_subscriber::fmt()
1791 .with_writer(logs.clone())
1792 .with_ansi(false)
1793 .without_time()
1794 .finish();
1795 let _guard = tracing::subscriber::set_default(subscriber);
1796
1797 let _policy = RbacPolicy::new(config);
1798 logs.contents()
1799 }
1800
1801 #[test]
1802 fn optional_non_empty_argument_allowlist_warns_once_at_policy_construction() {
1803 let config =
1804 allowlist_warning_policy(ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]));
1805
1806 let logs = capture_policy_construction_logs(&config);
1807
1808 assert_eq!(
1809 logs.matches("optional argument allowlist may fail open")
1810 .count(),
1811 1,
1812 "exactly one warning expected for one optional non-empty allowlist: {logs}"
1813 );
1814 assert!(logs.contains("run"), "warning must name the tool: {logs}");
1815 assert!(
1816 logs.contains("cmd"),
1817 "warning must name the argument: {logs}"
1818 );
1819 }
1820
1821 #[test]
1822 fn required_argument_allowlist_does_not_warn_at_policy_construction() {
1823 let config = allowlist_warning_policy(
1824 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]).with_required(true),
1825 );
1826
1827 let logs = capture_policy_construction_logs(&config);
1828
1829 assert!(
1830 !logs.contains("optional argument allowlist may fail open"),
1831 "required allowlist must not warn: {logs}"
1832 );
1833 }
1834
1835 #[test]
1836 fn new_required_sets_required_and_preserves_value_allowlist_behavior() {
1837 let optional = ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]);
1838 let required = ArgumentAllowlist::new_required("run", "cmd", vec!["ls".into()]);
1839
1840 assert_eq!(required.tool, optional.tool);
1841 assert_eq!(required.argument, optional.argument);
1842 assert_eq!(required.allowed, optional.allowed);
1843 assert!(required.required);
1844 assert!(!optional.required);
1845
1846 let optional_policy = RbacPolicy::new(&allowlist_warning_policy(optional));
1847 let required_policy = RbacPolicy::new(&allowlist_warning_policy(required));
1848 assert_eq!(
1849 optional_policy.argument_allowed("viewer", "run", "cmd", "ls -la"),
1850 required_policy.argument_allowed("viewer", "run", "cmd", "ls -la")
1851 );
1852 assert_eq!(
1853 optional_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /"),
1854 required_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /")
1855 );
1856 }
1857
1858 #[test]
1859 fn blank_redaction_salt_env_values_fail_closed() {
1860 for value in ["", "\n", " "] {
1861 with_rbac_env(
1862 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1863 || {
1864 let mut cfg = RbacConfig::default();
1865 let err = cfg.apply_env_overrides().unwrap_err();
1866 assert!(
1867 err.to_string()
1868 .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1869 );
1870 },
1871 );
1872 }
1873 }
1874
1875 #[test]
1876 fn blank_redaction_salt_file_values_fail_closed() {
1877 for value in ["", "\n", "\r\n", " \n"] {
1878 let err = redaction_from_file(value).unwrap_err();
1879 assert!(
1880 err.to_string()
1881 .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1882 );
1883 }
1884 }
1885
1886 fn redaction_from_direct_salt(salt: &str) -> String {
1887 RbacPolicy::new(&RbacConfig {
1888 redaction_salt: Some(SecretString::from(salt.to_owned())),
1889 ..RbacConfig::default()
1890 })
1891 .redact_arg("same-argument")
1892 }
1893
1894 fn redaction_from_file(
1895 content: &str,
1896 ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1897 let path = std::env::temp_dir().join(format!(
1898 "rmcp-server-kit-redaction-salt-{}.txt",
1899 std::time::SystemTime::now()
1900 .duration_since(std::time::UNIX_EPOCH)
1901 .expect("clock after epoch")
1902 .as_nanos()
1903 ));
1904 std::fs::write(&path, content).expect("write salt file");
1905 let path_string = path.to_string_lossy().to_string();
1906 let result = with_rbac_env(
1907 &[(
1908 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1909 Some(path_string.as_str()),
1910 )],
1911 || {
1912 let mut cfg = RbacConfig::default();
1913 let report = cfg.apply_env_overrides()?;
1914 let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1915 Ok((redaction, report))
1916 },
1917 );
1918 std::fs::remove_file(path).expect("remove salt file");
1919 result
1920 }
1921
1922 #[test]
1927 fn tool_limiter_burst_allows_initial_spike() {
1928 let limiter = build_tool_rate_limiter_with_policy(2, Some(4), KeyEvictionPolicy::default());
1929 let ip = RateLimitKey::Ip("10.9.9.9".parse::<IpAddr>().unwrap());
1930 for i in 0..4 {
1931 assert!(
1932 limiter.check_key(&ip).is_ok(),
1933 "burst request {i} should pass"
1934 );
1935 }
1936 assert!(
1937 limiter.check_key(&ip).is_err(),
1938 "request 5 must exceed the burst bucket"
1939 );
1940 }
1941
1942 #[test]
1944 fn tool_limiter_deny_sets_retry_after() {
1945 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
1946 let ip = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1947 assert!(enforce_rate_limit(Some(&limiter), Some(&ip)).is_none());
1948 let resp = enforce_rate_limit(Some(&limiter), Some(&ip))
1949 .expect("second call within the window must deny");
1950 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1951 let retry_after = resp
1952 .headers()
1953 .get(axum::http::header::RETRY_AFTER)
1954 .expect("Retry-After present")
1955 .to_str()
1956 .unwrap()
1957 .parse::<u64>()
1958 .unwrap();
1959 assert!(retry_after >= 1, "delta-seconds must be >= 1");
1960 }
1961
1962 #[test]
1963 fn tool_limiter_capacity_full_returns_503_without_retry_after() {
1964 let limiter = build_tool_rate_limiter_with_bounds(
1965 10,
1966 None,
1967 1,
1968 Duration::from_hours(1),
1969 KeyEvictionPolicy::RejectNew,
1970 );
1971 let established = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1972 let unseen = RateLimitKey::Ip("10.8.8.9".parse::<IpAddr>().unwrap());
1973 assert!(enforce_rate_limit(Some(&limiter), Some(&established)).is_none());
1974
1975 let resp = enforce_rate_limit(Some(&limiter), Some(&unseen))
1976 .expect("unseen key must be rejected at capacity");
1977
1978 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
1979 assert!(
1980 resp.headers()
1981 .get(axum::http::header::RETRY_AFTER)
1982 .is_none()
1983 );
1984 }
1985
1986 fn test_policy() -> RbacPolicy {
1987 RbacPolicy::new(&RbacConfig {
1988 enabled: true,
1989 roles: vec![
1990 RoleConfig {
1991 name: "viewer".into(),
1992 description: Some("Read-only".into()),
1993 allow: vec![
1994 "list_hosts".into(),
1995 "resource_list".into(),
1996 "resource_inspect".into(),
1997 "resource_logs".into(),
1998 "system_info".into(),
1999 ],
2000 deny: vec![],
2001 hosts: vec!["*".into()],
2002 argument_allowlists: vec![],
2003 },
2004 RoleConfig {
2005 name: "deploy".into(),
2006 description: Some("Lifecycle management".into()),
2007 allow: vec![
2008 "list_hosts".into(),
2009 "resource_list".into(),
2010 "resource_run".into(),
2011 "resource_start".into(),
2012 "resource_stop".into(),
2013 "resource_restart".into(),
2014 "resource_logs".into(),
2015 "image_pull".into(),
2016 ],
2017 deny: vec!["resource_delete".into(), "resource_exec".into()],
2018 hosts: vec!["web-*".into(), "api-*".into()],
2019 argument_allowlists: vec![],
2020 },
2021 RoleConfig {
2022 name: "ops".into(),
2023 description: Some("Full access".into()),
2024 allow: vec!["*".into()],
2025 deny: vec![],
2026 hosts: vec!["*".into()],
2027 argument_allowlists: vec![],
2028 },
2029 RoleConfig {
2030 name: "restricted-exec".into(),
2031 description: Some("Exec with argument allowlist".into()),
2032 allow: vec!["resource_exec".into()],
2033 deny: vec![],
2034 hosts: vec!["dev-*".into()],
2035 argument_allowlists: vec![ArgumentAllowlist {
2036 tool: "resource_exec".into(),
2037 argument: "cmd".into(),
2038 allowed: vec![
2039 "sh".into(),
2040 "bash".into(),
2041 "cat".into(),
2042 "ls".into(),
2043 "ps".into(),
2044 ],
2045 required: false,
2046 deny_unknown_arguments: false,
2047 }],
2048 },
2049 ],
2050 redaction_salt: None,
2051 ..RbacConfig::default()
2052 })
2053 }
2054
2055 #[test]
2058 fn glob_exact_match() {
2059 assert!(glob_match("web-prod-1", "web-prod-1"));
2060 assert!(!glob_match("web-prod-1", "web-prod-2"));
2061 }
2062
2063 #[test]
2064 fn glob_star_suffix() {
2065 assert!(glob_match("web-*", "web-prod-1"));
2066 assert!(glob_match("web-*", "web-staging"));
2067 assert!(!glob_match("web-*", "api-prod"));
2068 }
2069
2070 #[test]
2071 fn glob_star_prefix() {
2072 assert!(glob_match("*-prod", "web-prod"));
2073 assert!(glob_match("*-prod", "api-prod"));
2074 assert!(!glob_match("*-prod", "web-staging"));
2075 }
2076
2077 #[test]
2078 fn glob_star_middle() {
2079 assert!(glob_match("web-*-prod", "web-us-prod"));
2080 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
2081 assert!(!glob_match("web-*-prod", "web-staging"));
2082 }
2083
2084 #[test]
2085 fn glob_star_only() {
2086 assert!(glob_match("*", "anything"));
2087 assert!(glob_match("*", ""));
2088 }
2089
2090 #[test]
2091 fn glob_multiple_stars() {
2092 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
2093 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
2094 }
2095
2096 #[test]
2101 fn glob_match_multibyte_utf8() {
2102 assert!(glob_match("hé*llo", "héllo"));
2103 assert!(glob_match("*ö*", "wörld"));
2104 assert!(glob_match("über*", "übermensch"));
2105 assert!(glob_match("*界", "世界"));
2106 assert!(!glob_match("hé*llo", "hello"));
2107 assert!(!glob_match("界*", "世界"));
2108 assert!(glob_match("世*界", "世界"));
2109 }
2110
2111 #[test]
2123 fn glob_prefix_and_suffix_meet_exactly() {
2124 assert!(glob_match("ab*cd", "abcd"));
2127 }
2128
2129 #[test]
2134 fn glob_middle_segment_required_with_suffix() {
2135 assert!(!glob_match("a*b*c", "axyc"));
2140 }
2141
2142 #[test]
2148 fn glob_match_middle_advances_past_matched_part() {
2149 assert!(!glob_match("*ab*ab*", "xxab_yz"));
2154 }
2155
2156 #[test]
2161 fn glob_match_middle_uses_addition_not_multiplication() {
2162 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
2166 }
2167
2168 #[test]
2177 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
2178 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
2186 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2187 "run-*",
2188 "cmd",
2189 vec!["ls".into()],
2190 )]);
2191 let mut config = RbacConfig::with_roles(vec![role]);
2192 config.enabled = true;
2193 let policy = RbacPolicy::new(&config);
2194 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
2195 }
2196
2197 #[test]
2200 fn disabled_policy_allows_everything() {
2201 let policy = RbacPolicy::new(&RbacConfig {
2202 enabled: false,
2203 roles: vec![],
2204 redaction_salt: None,
2205 ..RbacConfig::default()
2206 });
2207 assert_eq!(
2208 policy.check("nonexistent", "resource_delete", "any-host"),
2209 RbacDecision::Allow
2210 );
2211 }
2212
2213 #[test]
2214 fn unknown_role_denied() {
2215 let policy = test_policy();
2216 assert_eq!(
2217 policy.check("unknown", "resource_list", "web-prod-1"),
2218 RbacDecision::Deny
2219 );
2220 }
2221
2222 #[test]
2223 fn viewer_allowed_read_ops() {
2224 let policy = test_policy();
2225 assert_eq!(
2226 policy.check("viewer", "resource_list", "web-prod-1"),
2227 RbacDecision::Allow
2228 );
2229 assert_eq!(
2230 policy.check("viewer", "system_info", "db-host"),
2231 RbacDecision::Allow
2232 );
2233 }
2234
2235 #[test]
2236 fn viewer_denied_write_ops() {
2237 let policy = test_policy();
2238 assert_eq!(
2239 policy.check("viewer", "resource_run", "web-prod-1"),
2240 RbacDecision::Deny
2241 );
2242 assert_eq!(
2243 policy.check("viewer", "resource_delete", "web-prod-1"),
2244 RbacDecision::Deny
2245 );
2246 }
2247
2248 #[test]
2249 fn deploy_allowed_on_matching_hosts() {
2250 let policy = test_policy();
2251 assert_eq!(
2252 policy.check("deploy", "resource_run", "web-prod-1"),
2253 RbacDecision::Allow
2254 );
2255 assert_eq!(
2256 policy.check("deploy", "resource_start", "api-staging"),
2257 RbacDecision::Allow
2258 );
2259 }
2260
2261 #[test]
2262 fn deploy_denied_on_non_matching_host() {
2263 let policy = test_policy();
2264 assert_eq!(
2265 policy.check("deploy", "resource_run", "db-prod-1"),
2266 RbacDecision::Deny
2267 );
2268 }
2269
2270 #[test]
2271 fn deny_overrides_allow() {
2272 let policy = test_policy();
2273 assert_eq!(
2274 policy.check("deploy", "resource_delete", "web-prod-1"),
2275 RbacDecision::Deny
2276 );
2277 assert_eq!(
2278 policy.check("deploy", "resource_exec", "web-prod-1"),
2279 RbacDecision::Deny
2280 );
2281 }
2282
2283 #[test]
2284 fn ops_wildcard_allows_everything() {
2285 let policy = test_policy();
2286 assert_eq!(
2287 policy.check("ops", "resource_delete", "any-host"),
2288 RbacDecision::Allow
2289 );
2290 assert_eq!(
2291 policy.check("ops", "secret_create", "db-host"),
2292 RbacDecision::Allow
2293 );
2294 }
2295
2296 #[test]
2299 fn host_visible_respects_globs() {
2300 let policy = test_policy();
2301 assert!(policy.host_visible("deploy", "web-prod-1"));
2302 assert!(policy.host_visible("deploy", "api-staging"));
2303 assert!(!policy.host_visible("deploy", "db-prod-1"));
2304 assert!(policy.host_visible("ops", "anything"));
2305 assert!(policy.host_visible("viewer", "anything"));
2306 }
2307
2308 #[test]
2309 fn host_visible_unknown_role() {
2310 let policy = test_policy();
2311 assert!(!policy.host_visible("unknown", "web-prod-1"));
2312 }
2313
2314 #[test]
2315 fn host_matching_is_ascii_case_insensitive() {
2316 let policy = test_policy();
2317 assert!(policy.host_visible("deploy", "WEB-PROD-1"));
2318 assert!(policy.host_visible("deploy", "Web-Prod-1"));
2319 assert!(policy.host_visible("deploy", "API-Staging"));
2320 assert!(!policy.host_visible("deploy", "DB-PROD-1"));
2321 }
2322
2323 #[test]
2324 fn check_host_matching_is_ascii_case_insensitive() {
2325 let policy = test_policy();
2326 assert_eq!(
2327 policy.check("deploy", "resource_run", "WEB-PROD-1"),
2328 RbacDecision::Allow
2329 );
2330 assert_eq!(
2331 policy.check("deploy", "resource_run", "DB-PROD-1"),
2332 RbacDecision::Deny
2333 );
2334 }
2335
2336 #[test]
2337 fn check_operation_names_remain_case_sensitive() {
2338 let policy = test_policy();
2339 assert_eq!(
2340 policy.check("deploy", "RESOURCE_RUN", "web-prod-1"),
2341 RbacDecision::Deny,
2342 "host normalization must not leak into operation matching"
2343 );
2344 }
2345
2346 #[test]
2347 fn tool_glob_matching_remains_case_sensitive() {
2348 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2351 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2352 "resource_*",
2353 "cmd",
2354 vec!["ls".into()],
2355 )]);
2356 let policy = RbacPolicy::new(&RbacConfig::with_roles(vec![role]));
2357
2358 assert!(policy.has_argument_allowlist("viewer", "resource_exec", "cmd"));
2359 assert!(
2360 !policy.has_argument_allowlist("viewer", "RESOURCE_EXEC", "cmd"),
2361 "tool patterns must not match case-insensitively"
2362 );
2363 assert!(!policy.argument_allowed("viewer", "resource_exec", "cmd", "rm"));
2364 }
2365
2366 #[test]
2369 fn argument_allowed_no_allowlist() {
2370 let policy = test_policy();
2371 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
2373 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
2374 }
2375
2376 #[test]
2377 fn argument_allowed_with_allowlist() {
2378 let policy = test_policy();
2379 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
2380 assert!(policy.argument_allowed(
2381 "restricted-exec",
2382 "resource_exec",
2383 "cmd",
2384 "bash -c 'echo hi'"
2385 ));
2386 assert!(policy.argument_allowed(
2387 "restricted-exec",
2388 "resource_exec",
2389 "cmd",
2390 "cat /etc/hosts"
2391 ));
2392 assert!(policy.argument_allowed(
2393 "restricted-exec",
2394 "resource_exec",
2395 "cmd",
2396 "/usr/bin/ls -la"
2397 ));
2398 }
2399
2400 #[test]
2401 fn argument_denied_not_in_allowlist() {
2402 let policy = test_policy();
2403 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
2404 assert!(!policy.argument_allowed(
2405 "restricted-exec",
2406 "resource_exec",
2407 "cmd",
2408 "python3 exploit.py"
2409 ));
2410 assert!(!policy.argument_allowed(
2411 "restricted-exec",
2412 "resource_exec",
2413 "cmd",
2414 "/usr/bin/curl evil.com"
2415 ));
2416 }
2417
2418 #[test]
2419 fn argument_denied_unknown_role() {
2420 let policy = test_policy();
2421 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
2422 }
2423
2424 fn strict_test_policy(allowlists: Vec<ArgumentAllowlist>) -> RbacPolicy {
2427 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2428 .with_argument_allowlists(allowlists);
2429 let mut config = RbacConfig::with_roles(vec![role]);
2430 config.enabled = true;
2431 RbacPolicy::new(&config)
2432 }
2433
2434 fn tool_call(args: serde_json::Value) -> serde_json::Value {
2435 let mut params = serde_json::Map::new();
2436 params.insert(
2437 "name".to_owned(),
2438 serde_json::Value::String("run".to_owned()),
2439 );
2440 params.insert("arguments".to_owned(), args);
2441 serde_json::Value::Object(params)
2442 }
2443
2444 #[test]
2445 fn unknown_arguments_are_admitted_when_strict_mode_is_off() {
2446 let policy = strict_test_policy(vec![ArgumentAllowlist::new(
2447 "run",
2448 "cmd",
2449 vec!["ls".into()],
2450 )]);
2451 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2452 assert!(
2453 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2454 "default behaviour must be unchanged: unnamed arguments pass"
2455 );
2456 }
2457
2458 #[test]
2459 fn strict_mode_rejects_unknown_arguments() {
2460 let policy = strict_test_policy(vec![
2461 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2462 .with_deny_unknown_arguments(true),
2463 ]);
2464 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2465 assert!(
2466 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2467 "an argument no allowlist names must be denied under strict mode"
2468 );
2469
2470 let permitted = tool_call(serde_json::json!({ "cmd": "ls" }));
2471 assert!(
2472 enforce_tool_policy(&policy, "u", "viewer", &permitted).is_none(),
2473 "an allowlisted argument must still pass"
2474 );
2475 }
2476
2477 #[test]
2478 fn strict_mode_rejects_structured_argument_values() {
2479 let policy = strict_test_policy(vec![
2480 ArgumentAllowlist::new("run", "cmd", vec![]).with_deny_unknown_arguments(true),
2481 ]);
2482 for shape in [
2483 serde_json::json!({ "nested": "x" }),
2484 serde_json::json!(["x"]),
2485 ] {
2486 let params = tool_call(serde_json::json!({ "cmd": shape }));
2487 assert!(
2488 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2489 "object/array values cannot be constrained and must be denied"
2490 );
2491 }
2492 }
2493
2494 #[test]
2495 fn strict_mode_permits_the_union_of_matching_allowlists() {
2496 let policy = strict_test_policy(vec![
2499 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2500 .with_deny_unknown_arguments(true),
2501 ArgumentAllowlist::new("run", "host", vec![]),
2502 ]);
2503 let params = tool_call(serde_json::json!({ "cmd": "ls", "host": "dev-1" }));
2504 assert!(
2505 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2506 "every matching allowlist's argument must remain permitted"
2507 );
2508 }
2509
2510 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
2519 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2520 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
2521 let mut config = RbacConfig::with_roles(vec![role]);
2522 config.enabled = true;
2523 RbacPolicy::new(&config)
2524 }
2525
2526 #[test]
2527 fn argument_allowed_matches_quoted_path_with_spaces() {
2528 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
2529 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2530 }
2531
2532 #[test]
2533 fn argument_allowed_matches_basename_of_quoted_path() {
2534 let policy = shlex_policy(vec!["my tool".into()]);
2535 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2536 }
2537
2538 #[test]
2539 fn argument_allowed_fails_closed_on_unbalanced_quote() {
2540 let policy = shlex_policy(vec!["unbalanced".into()]);
2541 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
2542 }
2543
2544 #[test]
2545 fn argument_allowed_fails_closed_on_empty_string() {
2546 let policy = shlex_policy(vec![String::new()]);
2547 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
2548 }
2549
2550 #[test]
2551 fn argument_allowed_handles_single_quoted_executable() {
2552 let policy = shlex_policy(vec!["/bin/sh".into()]);
2553 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
2554 }
2555
2556 #[test]
2557 fn argument_allowed_handles_tab_separator() {
2558 let policy = shlex_policy(vec!["ls".into()]);
2559 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
2560 }
2561
2562 #[test]
2563 fn argument_allowed_plain_token_unchanged() {
2564 let policy = shlex_policy(vec!["ls".into()]);
2565 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
2566 }
2567
2568 #[test]
2574 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
2575 let policy = shlex_policy(vec![String::new()]);
2579 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
2580 }
2581
2582 #[test]
2583 fn argument_allowed_quoted_literal_token_no_longer_matches() {
2584 let policy = shlex_policy(vec!["'bash'".into()]);
2590 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
2591 }
2592
2593 #[test]
2594 fn argument_allowed_backslash_literal_token_no_longer_matches() {
2595 let policy = shlex_policy(vec![r"foo\bar".into()]);
2600 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
2601 }
2602
2603 #[test]
2604 fn argument_allowed_windows_path_no_longer_matches() {
2605 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
2610 assert!(!policy.argument_allowed(
2611 "viewer",
2612 "run",
2613 "cmd",
2614 r"C:\Windows\System32\cmd.exe /c dir"
2615 ));
2616 }
2617
2618 #[test]
2621 fn host_patterns_returns_globs() {
2622 let policy = test_policy();
2623 assert_eq!(
2624 policy.host_patterns("deploy"),
2625 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
2626 );
2627 assert_eq!(
2628 policy.host_patterns("ops"),
2629 Some(vec!["*".to_owned()].as_slice())
2630 );
2631 assert!(policy.host_patterns("nonexistent").is_none());
2632 }
2633
2634 #[test]
2637 fn check_operation_allows_without_host() {
2638 let policy = test_policy();
2639 assert_eq!(
2640 policy.check_operation("deploy", "resource_run"),
2641 RbacDecision::Allow
2642 );
2643 assert_eq!(
2645 policy.check("deploy", "resource_run", "db-prod-1"),
2646 RbacDecision::Deny
2647 );
2648 }
2649
2650 #[test]
2651 fn check_operation_deny_overrides() {
2652 let policy = test_policy();
2653 assert_eq!(
2654 policy.check_operation("deploy", "resource_delete"),
2655 RbacDecision::Deny
2656 );
2657 }
2658
2659 #[test]
2660 fn check_operation_unknown_role() {
2661 let policy = test_policy();
2662 assert_eq!(
2663 policy.check_operation("unknown", "resource_list"),
2664 RbacDecision::Deny
2665 );
2666 }
2667
2668 #[test]
2669 fn check_operation_disabled() {
2670 let policy = RbacPolicy::new(&RbacConfig {
2671 enabled: false,
2672 roles: vec![],
2673 redaction_salt: None,
2674 ..RbacConfig::default()
2675 });
2676 assert_eq!(
2677 policy.check_operation("nonexistent", "anything"),
2678 RbacDecision::Allow
2679 );
2680 }
2681
2682 fn op_policy(role: RoleConfig) -> RbacPolicy {
2685 RbacPolicy::new(&RbacConfig::with_roles(vec![role]))
2686 }
2687
2688 fn glob_op_policy(role: RoleConfig) -> RbacPolicy {
2689 RbacPolicy::new(
2690 &RbacConfig::with_roles(vec![role])
2691 .with_allow_operation_matching(AllowOperationMatching::Glob),
2692 )
2693 }
2694
2695 #[test]
2696 fn deny_glob_blocks_under_allow_all() {
2697 let policy = op_policy(
2698 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2699 .with_deny(vec!["*_delete_*".into()]),
2700 );
2701 assert_eq!(
2702 policy.check_operation("editor", "jira_delete_issue"),
2703 RbacDecision::Deny
2704 );
2705 assert_eq!(
2706 policy.check_operation("editor", "confluence_delete_page"),
2707 RbacDecision::Deny
2708 );
2709 assert_eq!(
2710 policy.check_operation("editor", "jira_get_issue"),
2711 RbacDecision::Allow
2712 );
2713 }
2714
2715 #[test]
2716 fn deny_glob_blocks_in_host_scoped_check() {
2717 let policy = op_policy(
2718 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2719 .with_deny(vec!["jira_delete_*".into()]),
2720 );
2721 assert_eq!(
2722 policy.check("editor", "jira_delete_issue", "web-prod"),
2723 RbacDecision::Deny
2724 );
2725 assert_eq!(
2726 policy.check("editor", "jira_get_issue", "web-prod"),
2727 RbacDecision::Allow
2728 );
2729 }
2730
2731 #[test]
2732 fn deny_without_glob_still_matches_exactly() {
2733 let policy = op_policy(
2734 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2735 .with_deny(vec!["delete".into()]),
2736 );
2737 assert_eq!(
2738 policy.check_operation("editor", "delete"),
2739 RbacDecision::Deny
2740 );
2741 assert_eq!(
2742 policy.check_operation("editor", "delete_thing"),
2743 RbacDecision::Allow
2744 );
2745 assert_eq!(
2746 policy.check_operation("editor", "soft_delete"),
2747 RbacDecision::Allow
2748 );
2749 }
2750
2751 #[test]
2752 fn allow_glob_is_inert_in_legacy_mode() {
2753 let policy = op_policy(RoleConfig::new(
2754 "reader",
2755 vec!["jira_get_*".into()],
2756 vec!["*".into()],
2757 ));
2758 assert_eq!(
2759 policy.check_operation("reader", "jira_get_issue"),
2760 RbacDecision::Deny
2761 );
2762 assert_eq!(
2763 policy.check_operation("reader", "jira_get_*"),
2764 RbacDecision::Allow
2765 );
2766 }
2767
2768 #[test]
2769 fn allow_glob_is_honored_in_glob_mode() {
2770 let policy = glob_op_policy(RoleConfig::new(
2771 "reader",
2772 vec!["jira_get_*".into()],
2773 vec!["*".into()],
2774 ));
2775 assert_eq!(
2776 policy.check_operation("reader", "jira_get_issue"),
2777 RbacDecision::Allow
2778 );
2779 assert_eq!(
2780 policy.check_operation("reader", "confluence_get_page"),
2781 RbacDecision::Deny
2782 );
2783 }
2784
2785 #[test]
2786 fn allow_glob_mode_preserves_case_sensitivity() {
2787 let policy = glob_op_policy(RoleConfig::new(
2788 "reader",
2789 vec!["Jira_*".into()],
2790 vec!["*".into()],
2791 ));
2792 assert_eq!(
2793 policy.check_operation("reader", "jira_get_issue"),
2794 RbacDecision::Deny
2795 );
2796 assert_eq!(
2797 policy.check_operation("reader", "Jira_get_issue"),
2798 RbacDecision::Allow
2799 );
2800 }
2801
2802 #[test]
2803 fn allow_exact_entries_behave_identically_in_both_modes() {
2804 let role = RoleConfig::new(
2805 "reader",
2806 vec!["ping".into(), "list_hosts".into()],
2807 vec!["*".into()],
2808 );
2809 let legacy = op_policy(role.clone());
2810 let glob = glob_op_policy(role);
2811 for op in ["ping", "list_hosts", "delete", "pin", "pingg"] {
2812 assert_eq!(
2813 legacy.check_operation("reader", op),
2814 glob.check_operation("reader", op),
2815 "mode divergence on glob-free allow entry for {op}"
2816 );
2817 }
2818 }
2819
2820 #[test]
2821 fn allow_star_means_all_operations_in_both_modes() {
2822 let role = RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]);
2823 for policy in [op_policy(role.clone()), glob_op_policy(role)] {
2824 assert_eq!(
2825 policy.check_operation("admin", "anything_at_all"),
2826 RbacDecision::Allow
2827 );
2828 }
2829 }
2830
2831 #[test]
2832 fn global_deny_vetoes_allow_all() {
2833 let policy = RbacPolicy::new(
2834 &RbacConfig::with_roles(vec![RoleConfig::new(
2835 "admin",
2836 vec!["*".into()],
2837 vec!["*".into()],
2838 )])
2839 .with_global_deny(vec!["*_delete_*".into()]),
2840 );
2841 assert_eq!(
2842 policy.check_operation("admin", "jira_delete_issue"),
2843 RbacDecision::Deny
2844 );
2845 assert_eq!(
2846 policy.check("admin", "jira_delete_issue", "web-prod"),
2847 RbacDecision::Deny
2848 );
2849 assert_eq!(
2850 policy.check_operation("admin", "jira_get_issue"),
2851 RbacDecision::Allow
2852 );
2853 }
2854
2855 #[test]
2856 fn global_deny_globs_even_in_legacy_allow_mode() {
2857 let policy = RbacPolicy::new(
2858 &RbacConfig::with_roles(vec![RoleConfig::new(
2859 "admin",
2860 vec!["*".into()],
2861 vec!["*".into()],
2862 )])
2863 .with_allow_operation_matching(AllowOperationMatching::Legacy)
2864 .with_global_deny(vec!["danger_*".into()]),
2865 );
2866 assert_eq!(
2867 policy.check_operation("admin", "danger_wipe"),
2868 RbacDecision::Deny
2869 );
2870 }
2871
2872 #[test]
2873 fn global_deny_is_inert_when_rbac_disabled() {
2874 let policy = RbacPolicy::new(&RbacConfig {
2875 enabled: false,
2876 global_deny: vec!["*".into()],
2877 ..RbacConfig::default()
2878 });
2879 assert_eq!(
2880 policy.check_operation("anyone", "anything"),
2881 RbacDecision::Allow
2882 );
2883 }
2884
2885 #[test]
2886 fn global_deny_defaults_to_empty_and_changes_nothing() {
2887 let policy = op_policy(RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]));
2888 assert_eq!(
2889 policy.check_operation("admin", "jira_delete_issue"),
2890 RbacDecision::Allow
2891 );
2892 assert_eq!(policy.summary().global_deny, 0);
2893 }
2894
2895 #[test]
2896 fn empty_deny_entry_denies_only_the_empty_operation() {
2897 let policy = op_policy(
2898 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2899 .with_deny(vec![String::new()]),
2900 );
2901 assert_eq!(policy.check_operation("editor", ""), RbacDecision::Deny);
2902 assert_eq!(
2903 policy.check_operation("editor", "anything"),
2904 RbacDecision::Allow
2905 );
2906 }
2907
2908 #[test]
2909 fn empty_global_deny_entry_denies_only_the_empty_operation() {
2910 let policy = RbacPolicy::new(
2911 &RbacConfig::with_roles(vec![RoleConfig::new(
2912 "admin",
2913 vec!["*".into()],
2914 vec!["*".into()],
2915 )])
2916 .with_global_deny(vec![String::new()]),
2917 );
2918 assert_eq!(policy.check_operation("admin", ""), RbacDecision::Deny);
2919 assert_eq!(
2920 policy.check_operation("admin", "anything"),
2921 RbacDecision::Allow
2922 );
2923 }
2924
2925 #[test]
2926 fn star_deny_entry_denies_every_operation() {
2927 let policy = op_policy(
2928 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2929 .with_deny(vec!["*".into()]),
2930 );
2931 for op in ["", "ping", "jira_delete_issue"] {
2932 assert_eq!(policy.check_operation("editor", op), RbacDecision::Deny);
2933 assert_eq!(policy.check("editor", op, "web-prod"), RbacDecision::Deny);
2934 }
2935 }
2936
2937 #[test]
2938 fn star_global_deny_entry_denies_every_operation() {
2939 let policy = RbacPolicy::new(
2940 &RbacConfig::with_roles(vec![RoleConfig::new(
2941 "admin",
2942 vec!["*".into()],
2943 vec!["*".into()],
2944 )])
2945 .with_global_deny(vec!["*".into()]),
2946 );
2947 for op in ["", "ping", "jira_delete_issue"] {
2948 assert_eq!(policy.check_operation("admin", op), RbacDecision::Deny);
2949 }
2950 }
2951
2952 #[test]
2953 fn legacy_allow_matches_a_literal_star_in_an_operation_name() {
2954 let policy = op_policy(RoleConfig::new(
2955 "odd",
2956 vec!["weird_*_name".into()],
2957 vec!["*".into()],
2958 ));
2959 assert_eq!(
2960 policy.check_operation("odd", "weird_*_name"),
2961 RbacDecision::Allow
2962 );
2963 assert_eq!(
2964 policy.check_operation("odd", "weird_thing_name"),
2965 RbacDecision::Deny
2966 );
2967 }
2968
2969 #[test]
2970 fn deny_glob_matches_multibyte_operation_names() {
2971 let policy = op_policy(
2972 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2973 .with_deny(vec!["削除_*".into()]),
2974 );
2975 assert_eq!(
2976 policy.check_operation("editor", "削除_ページ"),
2977 RbacDecision::Deny
2978 );
2979 assert_eq!(
2980 policy.check_operation("editor", "取得_ページ"),
2981 RbacDecision::Allow
2982 );
2983 }
2984
2985 #[test]
2986 fn operation_matching_fields_deserialize_from_toml() {
2987 let cfg: RbacConfig = toml::from_str(
2988 r#"
2989 enabled = true
2990 allow_operation_matching = "glob"
2991 global_deny = ["*_purge_*"]
2992
2993 [[roles]]
2994 name = "ops"
2995 allow = ["jira_*"]
2996 hosts = ["*"]
2997 "#,
2998 )
2999 .expect("config parses");
3000 assert_eq!(
3001 cfg.allow_operation_matching,
3002 AllowOperationMatching::Glob,
3003 "kebab-case wire value must map to the Glob variant"
3004 );
3005 assert_eq!(cfg.global_deny, vec!["*_purge_*".to_owned()]);
3006
3007 let policy = RbacPolicy::new(&cfg);
3008 assert_eq!(
3009 policy.check_operation("ops", "jira_get_issue"),
3010 RbacDecision::Allow
3011 );
3012 assert_eq!(
3013 policy.check_operation("ops", "jira_purge_project"),
3014 RbacDecision::Deny
3015 );
3016 }
3017
3018 #[test]
3019 fn operation_matching_defaults_to_legacy_when_absent_from_toml() {
3020 let cfg: RbacConfig = toml::from_str("enabled = true").expect("config parses");
3021 assert_eq!(cfg.allow_operation_matching, AllowOperationMatching::Legacy);
3022 assert!(cfg.global_deny.is_empty());
3023 }
3024
3025 #[test]
3028 fn current_role_returns_none_outside_scope() {
3029 assert!(current_role().is_none());
3030 }
3031
3032 #[test]
3033 fn current_identity_returns_none_outside_scope() {
3034 assert!(current_identity().is_none());
3035 }
3036
3037 #[tokio::test]
3038 async fn empty_task_locals_are_all_absent() {
3039 with_rbac_scope(
3040 String::new(),
3041 String::new(),
3042 SecretString::from(String::new()),
3043 String::new(),
3044 async {
3045 assert!(current_role().is_none(), "empty role must be absent");
3046 assert!(
3047 current_identity().is_none(),
3048 "empty identity must be absent"
3049 );
3050 assert!(current_token().is_none(), "empty token must be absent");
3051 assert!(current_sub().is_none(), "empty sub must be absent");
3052 },
3053 )
3054 .await;
3055 }
3056
3057 #[tokio::test]
3058 async fn non_empty_task_locals_are_all_present() {
3059 with_rbac_scope(
3060 "viewer".to_owned(),
3061 "alice".to_owned(),
3062 SecretString::from("tok".to_owned()),
3063 "sub-1".to_owned(),
3064 async {
3065 assert_eq!(current_role().as_deref(), Some("viewer"));
3066 assert_eq!(current_identity().as_deref(), Some("alice"));
3067 assert!(current_token().is_some());
3068 assert_eq!(current_sub().as_deref(), Some("sub-1"));
3069 },
3070 )
3071 .await;
3072 }
3073
3074 #[tokio::test]
3079 async fn sub_or_identity_fallback_is_absent_for_empty_identity() {
3080 with_rbac_scope(
3081 "viewer".to_owned(),
3082 String::new(),
3083 SecretString::from(String::new()),
3084 String::new(),
3085 async {
3086 assert_eq!(current_role().as_deref(), Some("viewer"));
3087 assert!(
3088 current_sub().or_else(current_identity).is_none(),
3089 "empty identity must not satisfy a sub-or-identity fallback"
3090 );
3091 },
3092 )
3093 .await;
3094 }
3095
3096 use axum::{
3099 body::Body,
3100 http::{Method, Request, StatusCode},
3101 };
3102 use tower::ServiceExt as _;
3103
3104 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
3105 serde_json::json!({
3106 "jsonrpc": "2.0",
3107 "id": 1,
3108 "method": "tools/call",
3109 "params": {
3110 "name": tool,
3111 "arguments": args
3112 }
3113 })
3114 .to_string()
3115 }
3116
3117 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
3118 axum::Router::new()
3119 .route("/mcp", axum::routing::post(|| async { "ok" }))
3120 .layer(axum::middleware::from_fn(move |req, next| {
3121 let p = Arc::clone(&policy);
3122 rbac_middleware(p, None, req, next)
3123 }))
3124 }
3125
3126 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
3127 axum::Router::new()
3128 .route("/mcp", axum::routing::post(|| async { "ok" }))
3129 .layer(axum::middleware::from_fn(
3130 move |mut req: Request<Body>, next: Next| {
3131 let p = Arc::clone(&policy);
3132 let id = identity.clone();
3133 async move {
3134 req.extensions_mut().insert(id);
3135 rbac_middleware(p, None, req, next).await
3136 }
3137 },
3138 ))
3139 }
3140
3141 #[cfg(feature = "metrics")]
3145 #[tokio::test]
3146 async fn tool_limiter_deny_increments_counter() {
3147 use axum::extract::ConnectInfo;
3148
3149 let policy = Arc::new(test_policy());
3150 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
3151 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
3152 let identity = AuthIdentity {
3153 method: crate::auth::AuthMethod::BearerToken,
3154 name: "alice".into(),
3155 role: "viewer".into(),
3156 raw_token: None,
3157 sub: None,
3158 };
3159 let app = {
3160 let metrics = Arc::clone(&metrics);
3161 axum::Router::new()
3162 .route("/mcp", axum::routing::post(|| async { "ok" }))
3163 .layer(axum::middleware::from_fn(
3164 move |mut req: Request<Body>, next: Next| {
3165 let p = Arc::clone(&policy);
3166 let l = Arc::clone(&limiter);
3167 let id = identity.clone();
3168 let m = Arc::clone(&metrics);
3169 async move {
3170 req.extensions_mut().insert(id);
3171 req.extensions_mut().insert(m);
3172 let peer: std::net::SocketAddr =
3173 "10.9.9.1:40000".parse().expect("static socket addr parses");
3174 req.extensions_mut().insert(ConnectInfo(peer));
3175 rbac_middleware(p, Some(l), req, next).await
3176 }
3177 },
3178 ))
3179 };
3180 let mk = || {
3181 Request::builder()
3182 .method(Method::POST)
3183 .uri("/mcp")
3184 .header("content-type", "application/json")
3185 .body(Body::from(tool_call_body(
3186 "resource_list",
3187 &serde_json::json!({}),
3188 )))
3189 .unwrap()
3190 };
3191 let counter = || {
3192 metrics
3193 .rate_limited_total
3194 .with_label_values(&["tool"])
3195 .get()
3196 };
3197
3198 let first = app.clone().oneshot(mk()).await.unwrap();
3199 assert_eq!(first.status(), StatusCode::OK);
3200 assert_eq!(counter(), 0, "successful call must not count");
3201
3202 let denied = app.clone().oneshot(mk()).await.unwrap();
3203 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
3204 assert_eq!(counter(), 1, "deny must increment the tool label");
3205 }
3206
3207 #[tokio::test]
3208 async fn middleware_passes_non_post() {
3209 let policy = Arc::new(test_policy());
3210 let app = rbac_router(policy);
3211 let req = Request::builder()
3213 .method(Method::GET)
3214 .uri("/mcp")
3215 .body(Body::empty())
3216 .unwrap();
3217 let resp = app.oneshot(req).await.unwrap();
3220 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
3221 }
3222
3223 #[tokio::test]
3224 async fn middleware_denies_without_identity() {
3225 let policy = Arc::new(test_policy());
3226 let app = rbac_router(policy);
3227 let body = tool_call_body("resource_list", &serde_json::json!({}));
3228 let req = Request::builder()
3229 .method(Method::POST)
3230 .uri("/mcp")
3231 .header("content-type", "application/json")
3232 .body(Body::from(body))
3233 .unwrap();
3234 let resp = app.oneshot(req).await.unwrap();
3235 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3236 }
3237
3238 fn global_deny_identity() -> AuthIdentity {
3239 AuthIdentity {
3240 method: crate::auth::AuthMethod::BearerToken,
3241 name: "alice".into(),
3242 role: "admin".into(),
3243 raw_token: None,
3244 sub: None,
3245 }
3246 }
3247
3248 fn global_deny_policy() -> Arc<RbacPolicy> {
3249 Arc::new(RbacPolicy::new(
3250 &RbacConfig::with_roles(vec![RoleConfig::new(
3251 "admin",
3252 vec!["*".into()],
3253 vec!["*".into()],
3254 )])
3255 .with_global_deny(vec!["*_delete_*".into()]),
3256 ))
3257 }
3258
3259 async fn global_deny_call(args: serde_json::Value, tool: &str) -> StatusCode {
3260 let app = rbac_router_with_identity(global_deny_policy(), global_deny_identity());
3261 let req = Request::builder()
3262 .method(Method::POST)
3263 .uri("/mcp")
3264 .header("content-type", "application/json")
3265 .body(Body::from(tool_call_body(tool, &args)))
3266 .unwrap();
3267 app.oneshot(req).await.unwrap().status()
3268 }
3269
3270 #[tokio::test]
3271 async fn middleware_global_deny_blocks_hostless_tool_call() {
3272 assert_eq!(
3273 global_deny_call(serde_json::json!({}), "jira_delete_issue").await,
3274 StatusCode::FORBIDDEN
3275 );
3276 assert_eq!(
3277 global_deny_call(serde_json::json!({}), "jira_get_issue").await,
3278 StatusCode::OK
3279 );
3280 }
3281
3282 #[tokio::test]
3283 async fn middleware_global_deny_blocks_host_scoped_tool_call() {
3284 assert_eq!(
3285 global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_delete_issue").await,
3286 StatusCode::FORBIDDEN
3287 );
3288 assert_eq!(
3289 global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_get_issue").await,
3290 StatusCode::OK
3291 );
3292 }
3293
3294 #[tokio::test]
3295 async fn middleware_allows_permitted_tool() {
3296 let policy = Arc::new(test_policy());
3297 let id = AuthIdentity {
3298 method: crate::auth::AuthMethod::BearerToken,
3299 name: "alice".into(),
3300 role: "viewer".into(),
3301 raw_token: None,
3302 sub: None,
3303 };
3304 let app = rbac_router_with_identity(policy, id);
3305 let body = tool_call_body("resource_list", &serde_json::json!({}));
3306 let req = Request::builder()
3307 .method(Method::POST)
3308 .uri("/mcp")
3309 .header("content-type", "application/json")
3310 .body(Body::from(body))
3311 .unwrap();
3312 let resp = app.oneshot(req).await.unwrap();
3313 assert_eq!(resp.status(), StatusCode::OK);
3314 }
3315
3316 #[tokio::test]
3317 async fn middleware_denies_unpermitted_tool() {
3318 let policy = Arc::new(test_policy());
3319 let id = AuthIdentity {
3320 method: crate::auth::AuthMethod::BearerToken,
3321 name: "alice".into(),
3322 role: "viewer".into(),
3323 raw_token: None,
3324 sub: None,
3325 };
3326 let app = rbac_router_with_identity(policy, id);
3327 let body = tool_call_body("resource_delete", &serde_json::json!({}));
3328 let req = Request::builder()
3329 .method(Method::POST)
3330 .uri("/mcp")
3331 .header("content-type", "application/json")
3332 .body(Body::from(body))
3333 .unwrap();
3334 let resp = app.oneshot(req).await.unwrap();
3335 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3336 }
3337
3338 #[tokio::test]
3339 async fn middleware_passes_non_tool_call_post() {
3340 let policy = Arc::new(test_policy());
3341 let id = AuthIdentity {
3342 method: crate::auth::AuthMethod::BearerToken,
3343 name: "alice".into(),
3344 role: "viewer".into(),
3345 raw_token: None,
3346 sub: None,
3347 };
3348 let app = rbac_router_with_identity(policy, id);
3349 let body = serde_json::json!({
3351 "jsonrpc": "2.0",
3352 "id": 1,
3353 "method": "resources/list"
3354 })
3355 .to_string();
3356 let req = Request::builder()
3357 .method(Method::POST)
3358 .uri("/mcp")
3359 .header("content-type", "application/json")
3360 .body(Body::from(body))
3361 .unwrap();
3362 let resp = app.oneshot(req).await.unwrap();
3363 assert_eq!(resp.status(), StatusCode::OK);
3364 }
3365
3366 #[tokio::test]
3367 async fn middleware_enforces_argument_allowlist() {
3368 let policy = Arc::new(test_policy());
3369 let id = AuthIdentity {
3370 method: crate::auth::AuthMethod::BearerToken,
3371 name: "dev".into(),
3372 role: "restricted-exec".into(),
3373 raw_token: None,
3374 sub: None,
3375 };
3376 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
3378 let body = tool_call_body(
3379 "resource_exec",
3380 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
3381 );
3382 let req = Request::builder()
3383 .method(Method::POST)
3384 .uri("/mcp")
3385 .body(Body::from(body))
3386 .unwrap();
3387 let resp = app.oneshot(req).await.unwrap();
3388 assert_eq!(resp.status(), StatusCode::OK);
3389
3390 let app = rbac_router_with_identity(policy, id);
3392 let body = tool_call_body(
3393 "resource_exec",
3394 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
3395 );
3396 let req = Request::builder()
3397 .method(Method::POST)
3398 .uri("/mcp")
3399 .body(Body::from(body))
3400 .unwrap();
3401 let resp = app.oneshot(req).await.unwrap();
3402 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3403 }
3404
3405 #[tokio::test]
3406 async fn middleware_disabled_policy_passes_everything() {
3407 let policy = Arc::new(RbacPolicy::disabled());
3408 let app = rbac_router(policy);
3409 let body = tool_call_body("anything", &serde_json::json!({}));
3411 let req = Request::builder()
3412 .method(Method::POST)
3413 .uri("/mcp")
3414 .body(Body::from(body))
3415 .unwrap();
3416 let resp = app.oneshot(req).await.unwrap();
3417 assert_eq!(resp.status(), StatusCode::OK);
3418 }
3419
3420 #[tokio::test]
3421 async fn middleware_batch_all_allowed_passes() {
3422 let policy = Arc::new(test_policy());
3423 let id = AuthIdentity {
3424 method: crate::auth::AuthMethod::BearerToken,
3425 name: "alice".into(),
3426 role: "viewer".into(),
3427 raw_token: None,
3428 sub: None,
3429 };
3430 let app = rbac_router_with_identity(policy, id);
3431 let body = serde_json::json!([
3432 {
3433 "jsonrpc": "2.0",
3434 "id": 1,
3435 "method": "tools/call",
3436 "params": { "name": "resource_list", "arguments": {} }
3437 },
3438 {
3439 "jsonrpc": "2.0",
3440 "id": 2,
3441 "method": "tools/call",
3442 "params": { "name": "system_info", "arguments": {} }
3443 }
3444 ])
3445 .to_string();
3446 let req = Request::builder()
3447 .method(Method::POST)
3448 .uri("/mcp")
3449 .header("content-type", "application/json")
3450 .body(Body::from(body))
3451 .unwrap();
3452 let resp = app.oneshot(req).await.unwrap();
3453 assert_eq!(resp.status(), StatusCode::OK);
3454 }
3455
3456 #[tokio::test]
3457 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
3458 let policy = Arc::new(test_policy());
3459 let id = AuthIdentity {
3460 method: crate::auth::AuthMethod::BearerToken,
3461 name: "alice".into(),
3462 role: "viewer".into(),
3463 raw_token: None,
3464 sub: None,
3465 };
3466 let app = rbac_router_with_identity(policy, id);
3467 let body = serde_json::json!([
3468 {
3469 "jsonrpc": "2.0",
3470 "id": 1,
3471 "method": "tools/call",
3472 "params": { "name": "resource_list", "arguments": {} }
3473 },
3474 {
3475 "jsonrpc": "2.0",
3476 "id": 2,
3477 "method": "tools/call",
3478 "params": { "name": "resource_delete", "arguments": {} }
3479 }
3480 ])
3481 .to_string();
3482 let req = Request::builder()
3483 .method(Method::POST)
3484 .uri("/mcp")
3485 .header("content-type", "application/json")
3486 .body(Body::from(body))
3487 .unwrap();
3488 let resp = app.oneshot(req).await.unwrap();
3489 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3490 }
3491
3492 #[tokio::test]
3493 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
3494 let policy = Arc::new(test_policy());
3495 let id = AuthIdentity {
3496 method: crate::auth::AuthMethod::BearerToken,
3497 name: "dev".into(),
3498 role: "restricted-exec".into(),
3499 raw_token: None,
3500 sub: None,
3501 };
3502 let app = rbac_router_with_identity(policy, id);
3503 let body = serde_json::json!([
3504 {
3505 "jsonrpc": "2.0",
3506 "id": 1,
3507 "method": "tools/call",
3508 "params": {
3509 "name": "resource_exec",
3510 "arguments": { "cmd": "ls -la", "host": "dev-1" }
3511 }
3512 },
3513 {
3514 "jsonrpc": "2.0",
3515 "id": 2,
3516 "method": "tools/call",
3517 "params": {
3518 "name": "resource_exec",
3519 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
3520 }
3521 }
3522 ])
3523 .to_string();
3524 let req = Request::builder()
3525 .method(Method::POST)
3526 .uri("/mcp")
3527 .header("content-type", "application/json")
3528 .body(Body::from(body))
3529 .unwrap();
3530 let resp = app.oneshot(req).await.unwrap();
3531 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3532 }
3533
3534 #[test]
3537 fn redact_with_salt_is_deterministic_per_salt() {
3538 let salt = b"unit-test-salt";
3539 let a = redact_with_salt(salt, "rm -rf /");
3540 let b = redact_with_salt(salt, "rm -rf /");
3541 assert_eq!(a, b, "same input + salt must yield identical hash");
3542 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
3543 assert!(
3544 a.chars().all(|c| c.is_ascii_hexdigit()),
3545 "redacted hash must be lowercase hex: {a}"
3546 );
3547 }
3548
3549 #[test]
3550 fn redact_with_salt_differs_across_salts() {
3551 let v = "the-same-value";
3552 let h1 = redact_with_salt(b"salt-one", v);
3553 let h2 = redact_with_salt(b"salt-two", v);
3554 assert_ne!(
3555 h1, h2,
3556 "different salts must produce different hashes for the same value"
3557 );
3558 }
3559
3560 #[test]
3561 fn redact_with_salt_distinguishes_values() {
3562 let salt = b"k";
3563 let h1 = redact_with_salt(salt, "alpha");
3564 let h2 = redact_with_salt(salt, "beta");
3565 assert_ne!(h1, h2, "different values must produce different hashes");
3567 }
3568
3569 #[test]
3570 fn policy_with_configured_salt_redacts_consistently() {
3571 let cfg = RbacConfig {
3572 enabled: true,
3573 roles: vec![],
3574 redaction_salt: Some(SecretString::from("my-stable-salt")),
3575 ..RbacConfig::default()
3576 };
3577 let p1 = RbacPolicy::new(&cfg);
3578 let p2 = RbacPolicy::new(&cfg);
3579 assert_eq!(
3580 p1.redact_arg("payload"),
3581 p2.redact_arg("payload"),
3582 "policies built from the same configured salt must agree"
3583 );
3584 }
3585
3586 #[test]
3587 fn policy_without_configured_salt_uses_process_salt() {
3588 let cfg = RbacConfig {
3589 enabled: true,
3590 roles: vec![],
3591 redaction_salt: None,
3592 ..RbacConfig::default()
3593 };
3594 let p1 = RbacPolicy::new(&cfg);
3595 let p2 = RbacPolicy::new(&cfg);
3596 assert_eq!(
3598 p1.redact_arg("payload"),
3599 p2.redact_arg("payload"),
3600 "process-wide salt must be consistent within one process"
3601 );
3602 }
3603
3604 #[tokio::test]
3616 async fn deny_path_uses_explicit_identity_not_task_local() {
3617 let policy = Arc::new(test_policy());
3618 let id = AuthIdentity {
3619 method: crate::auth::AuthMethod::BearerToken,
3620 name: "alice-the-auditor".into(),
3621 role: "viewer".into(),
3622 raw_token: None,
3623 sub: None,
3624 };
3625 let app = rbac_router_with_identity(policy, id);
3626 let body = tool_call_body("resource_delete", &serde_json::json!({}));
3628 let req = Request::builder()
3629 .method(Method::POST)
3630 .uri("/mcp")
3631 .header("content-type", "application/json")
3632 .body(Body::from(body))
3633 .unwrap();
3634 let resp = app.oneshot(req).await.unwrap();
3635 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3636 }
3637
3638 fn restricted_exec_identity() -> AuthIdentity {
3641 AuthIdentity {
3642 method: crate::auth::AuthMethod::BearerToken,
3643 name: "carol".into(),
3644 role: "restricted-exec".into(),
3645 raw_token: None,
3646 sub: None,
3647 }
3648 }
3649
3650 #[test]
3651 fn has_argument_allowlist_matches_configured_tool_argument() {
3652 let policy = test_policy();
3653 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
3654 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
3655 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
3656 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
3657 }
3658
3659 #[tokio::test]
3660 async fn array_arg_with_matching_allowlist_is_denied() {
3661 let policy = Arc::new(test_policy());
3662 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3663 let body = tool_call_body(
3664 "resource_exec",
3665 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
3666 );
3667 let req = Request::builder()
3668 .method(Method::POST)
3669 .uri("/mcp")
3670 .header("content-type", "application/json")
3671 .body(Body::from(body))
3672 .unwrap();
3673 let resp = app.oneshot(req).await.unwrap();
3674 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3675 }
3676
3677 #[tokio::test]
3678 async fn object_arg_with_matching_allowlist_is_denied() {
3679 let policy = Arc::new(test_policy());
3680 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3681 let body = tool_call_body(
3682 "resource_exec",
3683 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
3684 );
3685 let req = Request::builder()
3686 .method(Method::POST)
3687 .uri("/mcp")
3688 .header("content-type", "application/json")
3689 .body(Body::from(body))
3690 .unwrap();
3691 let resp = app.oneshot(req).await.unwrap();
3692 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3693 }
3694
3695 #[tokio::test]
3696 async fn number_arg_with_matching_allowlist_is_denied() {
3697 let policy = Arc::new(test_policy());
3698 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3699 let body = tool_call_body(
3700 "resource_exec",
3701 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
3702 );
3703 let req = Request::builder()
3704 .method(Method::POST)
3705 .uri("/mcp")
3706 .header("content-type", "application/json")
3707 .body(Body::from(body))
3708 .unwrap();
3709 let resp = app.oneshot(req).await.unwrap();
3710 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3711 }
3712
3713 #[tokio::test]
3714 async fn bool_arg_with_matching_allowlist_is_denied() {
3715 let policy = Arc::new(test_policy());
3716 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3717 let body = tool_call_body(
3718 "resource_exec",
3719 &serde_json::json!({ "host": "dev-1", "cmd": true }),
3720 );
3721 let req = Request::builder()
3722 .method(Method::POST)
3723 .uri("/mcp")
3724 .header("content-type", "application/json")
3725 .body(Body::from(body))
3726 .unwrap();
3727 let resp = app.oneshot(req).await.unwrap();
3728 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3729 }
3730
3731 #[tokio::test]
3732 async fn null_arg_with_matching_allowlist_is_denied() {
3733 let policy = Arc::new(test_policy());
3734 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3735 let body = tool_call_body(
3736 "resource_exec",
3737 &serde_json::json!({ "host": "dev-1", "cmd": null }),
3738 );
3739 let req = Request::builder()
3740 .method(Method::POST)
3741 .uri("/mcp")
3742 .header("content-type", "application/json")
3743 .body(Body::from(body))
3744 .unwrap();
3745 let resp = app.oneshot(req).await.unwrap();
3746 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3747 }
3748
3749 #[tokio::test]
3750 async fn non_string_arg_without_allowlist_is_passthrough() {
3751 let policy = Arc::new(test_policy());
3755 let id = AuthIdentity {
3756 method: crate::auth::AuthMethod::BearerToken,
3757 name: "olivia".into(),
3758 role: "ops".into(),
3759 raw_token: None,
3760 sub: None,
3761 };
3762 let app = rbac_router_with_identity(policy, id);
3763 let body = tool_call_body(
3764 "resource_exec",
3765 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
3766 );
3767 let req = Request::builder()
3768 .method(Method::POST)
3769 .uri("/mcp")
3770 .header("content-type", "application/json")
3771 .body(Body::from(body))
3772 .unwrap();
3773 let resp = app.oneshot(req).await.unwrap();
3774 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3775 }
3776
3777 #[tokio::test]
3778 async fn string_arg_in_allowlist_still_passes() {
3779 let policy = Arc::new(test_policy());
3780 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3781 let body = tool_call_body(
3782 "resource_exec",
3783 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
3784 );
3785 let req = Request::builder()
3786 .method(Method::POST)
3787 .uri("/mcp")
3788 .header("content-type", "application/json")
3789 .body(Body::from(body))
3790 .unwrap();
3791 let resp = app.oneshot(req).await.unwrap();
3792 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3793 }
3794
3795 async fn exec_status(args: &serde_json::Value) -> StatusCode {
3804 let policy = Arc::new(test_policy());
3805 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3806 let body = tool_call_body("resource_exec", args);
3807 let req = Request::builder()
3808 .method(Method::POST)
3809 .uri("/mcp")
3810 .header("content-type", "application/json")
3811 .body(Body::from(body))
3812 .unwrap();
3813 app.oneshot(req).await.unwrap().status()
3814 }
3815
3816 #[tokio::test]
3817 async fn non_string_host_is_denied_for_every_json_type() {
3818 for host in [
3819 serde_json::json!(["prod-1"]),
3820 serde_json::json!({ "name": "prod-1" }),
3821 serde_json::json!(42),
3822 serde_json::json!(true),
3823 serde_json::json!(null),
3824 ] {
3825 let args = serde_json::json!({ "host": host, "cmd": "sh" });
3826 assert_eq!(
3827 exec_status(&args).await,
3828 StatusCode::FORBIDDEN,
3829 "non-string host must not bypass host globs: {host:?}"
3830 );
3831 }
3832 }
3833
3834 #[tokio::test]
3835 async fn string_host_outside_globs_still_denied() {
3836 let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
3837 assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
3838 }
3839
3840 #[tokio::test]
3841 async fn string_host_inside_globs_still_allowed() {
3842 let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
3843 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3844 }
3845
3846 #[tokio::test]
3850 async fn absent_host_still_routes_to_check_operation() {
3851 let args = serde_json::json!({ "cmd": "sh" });
3852 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3853 }
3854
3855 fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
3864 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
3865 .with_argument_allowlists(vec![
3866 ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
3867 ]);
3868 let mut config = RbacConfig::with_roles(vec![role]);
3869 config.enabled = true;
3870 RbacPolicy::new(&config)
3871 }
3872
3873 fn viewer_identity() -> AuthIdentity {
3874 AuthIdentity {
3875 method: crate::auth::AuthMethod::BearerToken,
3876 name: "viewer-1".into(),
3877 role: "viewer".into(),
3878 raw_token: None,
3879 sub: None,
3880 }
3881 }
3882
3883 async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
3884 let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
3885 let body = serde_json::json!({
3886 "jsonrpc": "2.0",
3887 "id": 1,
3888 "method": "tools/call",
3889 "params": params
3890 })
3891 .to_string();
3892 let req = Request::builder()
3893 .method(Method::POST)
3894 .uri("/mcp")
3895 .header("content-type", "application/json")
3896 .body(Body::from(body))
3897 .unwrap();
3898 app.oneshot(req).await.unwrap().status()
3899 }
3900
3901 #[tokio::test]
3902 async fn required_false_still_allows_omitting_the_argument() {
3903 let params = serde_json::json!({ "name": "run", "arguments": {} });
3904 assert_ne!(
3905 run_status(required_policy(vec!["ls".into()], false), ¶ms).await,
3906 StatusCode::FORBIDDEN,
3907 "default behaviour must be unchanged"
3908 );
3909 }
3910
3911 #[tokio::test]
3912 async fn required_true_denies_omitted_argument() {
3913 let params = serde_json::json!({ "name": "run", "arguments": {} });
3914 assert_eq!(
3915 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3916 StatusCode::FORBIDDEN
3917 );
3918 }
3919
3920 #[tokio::test]
3921 async fn required_true_allows_permitted_value() {
3922 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
3923 assert_ne!(
3924 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3925 StatusCode::FORBIDDEN
3926 );
3927 }
3928
3929 #[tokio::test]
3930 async fn required_true_still_denies_disallowed_value() {
3931 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
3932 assert_eq!(
3933 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3934 StatusCode::FORBIDDEN
3935 );
3936 }
3937
3938 #[tokio::test]
3939 async fn required_true_denies_non_string_value() {
3940 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
3941 assert_eq!(
3942 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3943 StatusCode::FORBIDDEN
3944 );
3945 }
3946
3947 #[tokio::test]
3948 async fn required_true_denies_absent_or_non_object_arguments() {
3949 for params in [
3950 serde_json::json!({ "name": "run" }),
3951 serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
3952 serde_json::json!({ "name": "run", "arguments": null }),
3953 ] {
3954 assert_eq!(
3955 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3956 StatusCode::FORBIDDEN,
3957 "omitting the arguments object must not skip `required`: {params:?}"
3958 );
3959 }
3960 }
3961
3962 #[tokio::test]
3965 async fn required_true_with_empty_allowed_accepts_any_string() {
3966 let params =
3967 serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
3968 assert_ne!(
3969 run_status(required_policy(vec![], true), ¶ms).await,
3970 StatusCode::FORBIDDEN
3971 );
3972 }
3973
3974 #[tokio::test]
3975 async fn required_true_with_empty_allowed_denies_omitted_argument() {
3976 let params = serde_json::json!({ "name": "run", "arguments": {} });
3977 assert_eq!(
3978 run_status(required_policy(vec![], true), ¶ms).await,
3979 StatusCode::FORBIDDEN
3980 );
3981 }
3982
3983 #[tokio::test]
3984 async fn required_true_with_empty_allowed_denies_non_string() {
3985 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
3986 assert_eq!(
3987 run_status(required_policy(vec![], true), ¶ms).await,
3988 StatusCode::FORBIDDEN
3989 );
3990 }
3991
3992 #[tokio::test]
3993 async fn required_honours_globbed_tool_patterns() {
3994 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
3995 .with_argument_allowlists(vec![
3996 ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
3997 ]);
3998 let mut config = RbacConfig::with_roles(vec![role]);
3999 config.enabled = true;
4000 let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
4001 assert_eq!(
4002 run_status(RbacPolicy::new(&config), ¶ms).await,
4003 StatusCode::FORBIDDEN,
4004 "a globbed tool pattern must enforce presence, not just value"
4005 );
4006 }
4007
4008 #[test]
4009 fn required_defaults_to_false_when_absent_from_toml() {
4010 let cfg: RbacConfig = toml::from_str(
4011 r#"
4012 enabled = true
4013 [[roles]]
4014 name = "viewer"
4015 allow = ["run"]
4016 [[roles.argument_allowlists]]
4017 tool = "run"
4018 argument = "cmd"
4019 allowed = ["ls"]
4020 "#,
4021 )
4022 .expect("config without `required` must still deserialize");
4023 assert!(
4024 !cfg.roles[0].argument_allowlists[0].required,
4025 "omitted `required` must default to false so existing configs are unchanged"
4026 );
4027 }
4028
4029 #[test]
4030 fn unknown_rbac_config_key_is_rejected() {
4031 let err = toml::from_str::<RbacConfig>(
4032 "
4033 enabled = true
4034 typo_roles = []
4035 ",
4036 )
4037 .unwrap_err();
4038
4039 let msg = err.to_string();
4040 assert!(
4041 msg.contains("typo_roles"),
4042 "error must name the offending key: {msg}"
4043 );
4044 }
4045}