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.try_with(Clone::clone).ok()
140}
141
142#[must_use]
145pub fn current_identity() -> Option<String> {
146 CURRENT_IDENTITY.try_with(Clone::clone).ok()
147}
148
149#[must_use]
163pub fn current_token() -> Option<SecretString> {
164 CURRENT_TOKEN
165 .try_with(|t| {
166 if t.expose_secret().is_empty() {
167 None
168 } else {
169 Some(t.clone())
170 }
171 })
172 .ok()
173 .flatten()
174}
175
176#[must_use]
180pub fn current_sub() -> Option<String> {
181 CURRENT_SUB
182 .try_with(Clone::clone)
183 .ok()
184 .filter(|s| !s.is_empty())
185}
186
187pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
194 CURRENT_TOKEN.scope(token, f).await
195}
196
197pub async fn with_rbac_scope<F: Future>(
204 role: String,
205 identity: String,
206 token: SecretString,
207 sub: String,
208 f: F,
209) -> F::Output {
210 with_rbac_scope_lazy(role, identity, token, sub, || f).await
211}
212
213pub(crate) async fn with_rbac_scope_lazy<T, F, Fut>(
214 role: String,
215 identity: String,
216 token: SecretString,
217 sub: String,
218 f: F,
219) -> T
220where
221 F: FnOnce() -> Fut,
222 Fut: Future<Output = T>,
223{
224 CURRENT_ROLE
225 .scope(role, async move {
226 CURRENT_IDENTITY
227 .scope(identity, async move {
228 CURRENT_TOKEN
229 .scope(token, async move {
230 CURRENT_SUB.scope(sub, async move { f().await }).await
231 })
232 .await
233 })
234 .await
235 })
236 .await
237}
238
239#[derive(Debug, Clone, Deserialize)]
241#[serde(deny_unknown_fields)]
242#[non_exhaustive]
243pub struct RoleConfig {
244 pub name: String,
246 #[serde(default)]
248 pub description: Option<String>,
249 #[serde(default)]
251 pub allow: Vec<String>,
252 #[serde(default)]
254 pub deny: Vec<String>,
255 #[serde(default = "default_hosts")]
257 pub hosts: Vec<String>,
258 #[serde(default)]
262 pub argument_allowlists: Vec<ArgumentAllowlist>,
263}
264
265impl RoleConfig {
266 #[must_use]
268 pub fn new(name: impl Into<String>, allow: Vec<String>, hosts: Vec<String>) -> Self {
269 Self {
270 name: name.into(),
271 description: None,
272 allow,
273 deny: vec![],
274 hosts,
275 argument_allowlists: vec![],
276 }
277 }
278
279 #[must_use]
281 pub fn with_deny(mut self, deny: Vec<String>) -> Self {
282 self.deny = deny;
283 self
284 }
285
286 #[must_use]
288 pub fn with_argument_allowlists(mut self, allowlists: Vec<ArgumentAllowlist>) -> Self {
289 self.argument_allowlists = allowlists;
290 self
291 }
292}
293
294#[derive(Debug, Clone, Deserialize)]
328#[serde(deny_unknown_fields)]
329#[non_exhaustive]
330pub struct ArgumentAllowlist {
331 pub tool: String,
333 pub argument: String,
335 #[serde(default)]
337 pub allowed: Vec<String>,
338 #[serde(default)]
351 pub required: bool,
352 #[serde(default)]
366 pub deny_unknown_arguments: bool,
367}
368
369impl ArgumentAllowlist {
370 #[must_use]
376 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
377 Self {
378 tool: tool.into(),
379 argument: argument.into(),
380 allowed,
381 required: false,
382 deny_unknown_arguments: false,
383 }
384 }
385
386 #[must_use]
391 pub fn new_required(
392 tool: impl Into<String>,
393 argument: impl Into<String>,
394 allowed: Vec<String>,
395 ) -> Self {
396 Self::new(tool, argument, allowed).with_required(true)
397 }
398
399 #[must_use]
401 pub const fn with_required(mut self, required: bool) -> Self {
402 self.required = required;
403 self
404 }
405
406 #[must_use]
411 pub const fn with_deny_unknown_arguments(mut self, deny: bool) -> Self {
412 self.deny_unknown_arguments = deny;
413 self
414 }
415}
416
417fn default_hosts() -> Vec<String> {
418 vec!["*".into()]
419}
420
421#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
430#[serde(rename_all = "kebab-case")]
431#[non_exhaustive]
432pub enum AllowOperationMatching {
433 #[default]
439 Legacy,
440 Glob,
445}
446
447#[derive(Debug, Clone, Default, Deserialize)]
449#[serde(deny_unknown_fields)]
450#[non_exhaustive]
451pub struct RbacConfig {
452 #[serde(default)]
454 pub enabled: bool,
455 #[serde(default)]
457 pub roles: Vec<RoleConfig>,
458 #[serde(default)]
462 pub allow_operation_matching: AllowOperationMatching,
463 #[serde(default)]
478 pub global_deny: Vec<String>,
479 #[serde(default)]
488 pub redaction_salt: Option<SecretString>,
489}
490
491impl RbacConfig {
492 #[must_use]
494 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
495 Self {
496 enabled: true,
497 roles,
498 allow_operation_matching: AllowOperationMatching::default(),
499 global_deny: Vec::new(),
500 redaction_salt: None,
501 }
502 }
503
504 #[must_use]
506 pub fn with_global_deny(mut self, global_deny: Vec<String>) -> Self {
507 self.global_deny = global_deny;
508 self
509 }
510
511 #[must_use]
513 pub fn with_allow_operation_matching(mut self, mode: AllowOperationMatching) -> Self {
514 self.allow_operation_matching = mode;
515 self
516 }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521#[non_exhaustive]
522pub enum RbacDecision {
523 Allow,
525 Deny,
527}
528
529#[derive(Debug, Clone, serde::Serialize)]
531#[non_exhaustive]
532pub struct RbacRoleSummary {
533 pub name: String,
535 pub allow: usize,
537 pub deny: usize,
539 pub hosts: usize,
541 pub argument_allowlists: usize,
543}
544
545#[derive(Debug, Clone, serde::Serialize)]
547#[non_exhaustive]
548pub struct RbacPolicySummary {
549 pub enabled: bool,
551 pub global_deny: usize,
553 pub roles: Vec<RbacRoleSummary>,
555}
556
557#[derive(Debug, Clone)]
563#[non_exhaustive]
564pub struct RbacPolicy {
565 roles: Vec<RoleConfig>,
566 enabled: bool,
567 allow_operation_matching: AllowOperationMatching,
568 global_deny: Vec<String>,
569 redaction_salt: Arc<SecretString>,
572}
573
574impl RbacPolicy {
575 #[must_use]
578 pub fn new(config: &RbacConfig) -> Self {
579 warn_on_optional_value_allowlists(&config.roles);
580 warn_on_literal_allow_globs(&config.roles, config.allow_operation_matching);
581 warn_on_inert_global_deny(config);
582 let salt = config
583 .redaction_salt
584 .clone()
585 .unwrap_or_else(|| process_redaction_salt().clone());
586 Self {
587 roles: config.roles.clone(),
588 enabled: config.enabled,
589 allow_operation_matching: config.allow_operation_matching,
590 global_deny: config.global_deny.clone(),
591 redaction_salt: Arc::new(salt),
592 }
593 }
594
595 #[must_use]
597 pub fn disabled() -> Self {
598 Self {
599 roles: Vec::new(),
600 enabled: false,
601 allow_operation_matching: AllowOperationMatching::default(),
602 global_deny: Vec::new(),
603 redaction_salt: Arc::new(process_redaction_salt().clone()),
604 }
605 }
606
607 #[must_use]
609 pub fn is_enabled(&self) -> bool {
610 self.enabled
611 }
612
613 #[must_use]
618 pub fn summary(&self) -> RbacPolicySummary {
619 let roles = self
620 .roles
621 .iter()
622 .map(|r| RbacRoleSummary {
623 name: r.name.clone(),
624 allow: r.allow.len(),
625 deny: r.deny.len(),
626 hosts: r.hosts.len(),
627 argument_allowlists: r.argument_allowlists.len(),
628 })
629 .collect();
630 RbacPolicySummary {
631 enabled: self.enabled,
632 global_deny: self.global_deny.len(),
633 roles,
634 }
635 }
636
637 fn global_denied(&self, operation: &str) -> bool {
642 self.global_deny.iter().any(|d| glob_match(d, operation))
643 }
644
645 fn role_denies(role_cfg: &RoleConfig, operation: &str) -> bool {
652 role_cfg.deny.iter().any(|d| glob_match(d, operation))
653 }
654
655 fn role_allows(&self, role_cfg: &RoleConfig, operation: &str) -> bool {
657 role_cfg.allow.iter().any(|a| {
658 a == "*"
659 || match self.allow_operation_matching {
660 AllowOperationMatching::Legacy => a == operation,
661 AllowOperationMatching::Glob => glob_match(a, operation),
662 }
663 })
664 }
665
666 #[must_use]
671 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
672 if !self.enabled {
673 return RbacDecision::Allow;
674 }
675 if self.global_denied(operation) {
676 return RbacDecision::Deny;
677 }
678 let Some(role_cfg) = self.find_role(role) else {
679 return RbacDecision::Deny;
680 };
681 if Self::role_denies(role_cfg, operation) {
682 return RbacDecision::Deny;
683 }
684 if self.role_allows(role_cfg, operation) {
685 return RbacDecision::Allow;
686 }
687 RbacDecision::Deny
688 }
689
690 #[must_use]
700 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
701 if !self.enabled {
702 return RbacDecision::Allow;
703 }
704 if self.global_denied(operation) {
705 return RbacDecision::Deny;
706 }
707 let Some(role_cfg) = self.find_role(role) else {
708 return RbacDecision::Deny;
709 };
710 if Self::role_denies(role_cfg, operation) {
711 return RbacDecision::Deny;
712 }
713 if !self.role_allows(role_cfg, operation) {
714 return RbacDecision::Deny;
715 }
716 if !Self::host_matches(&role_cfg.hosts, host) {
717 return RbacDecision::Deny;
718 }
719 RbacDecision::Allow
720 }
721
722 #[must_use]
726 pub fn host_visible(&self, role: &str, host: &str) -> bool {
727 if !self.enabled {
728 return true;
729 }
730 let Some(role_cfg) = self.find_role(role) else {
731 return false;
732 };
733 Self::host_matches(&role_cfg.hosts, host)
734 }
735
736 #[must_use]
738 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
739 self.find_role(role).map(|r| r.hosts.as_slice())
740 }
741
742 #[must_use]
781 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
782 if !self.enabled {
783 return true;
784 }
785 let Some(role_cfg) = self.find_role(role) else {
786 return false;
787 };
788 for al in &role_cfg.argument_allowlists {
789 if al.tool != tool && !glob_match(&al.tool, tool) {
790 continue;
791 }
792 if al.argument != argument {
793 continue;
794 }
795 if al.allowed.is_empty() {
796 continue;
797 }
798 let Some(tokens) = shlex::split(value) else {
803 return false;
804 };
805 let Some(first_token) = tokens.first() else {
806 return false;
807 };
808 if first_token.is_empty() {
812 return false;
813 }
814 let basename = first_token
818 .rsplit('/')
819 .next()
820 .unwrap_or(first_token.as_str());
821 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
822 return false;
823 }
824 }
825 true
826 }
827
828 #[must_use]
838 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
839 if !self.enabled {
840 return false;
841 }
842 let Some(role_cfg) = self.find_role(role) else {
843 return false;
844 };
845 role_cfg.argument_allowlists.iter().any(|al| {
846 (al.tool == tool || glob_match(&al.tool, tool))
847 && al.argument == argument
848 && !al.allowed.is_empty()
849 })
850 }
851
852 fn strict_argument_names(&self, role: &str, tool: &str) -> Option<Vec<&str>> {
862 if !self.enabled {
863 return None;
864 }
865 let role_cfg = self.find_role(role)?;
866 let matching = || {
867 role_cfg
868 .argument_allowlists
869 .iter()
870 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
871 };
872 if !matching().any(|al| al.deny_unknown_arguments) {
873 return None;
874 }
875 Some(matching().map(|al| al.argument.as_str()).collect())
876 }
877
878 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
880 self.roles.iter().find(|r| r.name == name)
881 }
882
883 fn missing_required_argument(
894 &self,
895 role: &str,
896 tool: &str,
897 args: Option<&serde_json::Map<String, serde_json::Value>>,
898 ) -> Option<&str> {
899 if !self.enabled {
900 return None;
901 }
902 let role_cfg = self.find_role(role)?;
903 role_cfg
904 .argument_allowlists
905 .iter()
906 .filter(|al| al.required)
907 .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
911 .find(|al| {
912 !args.is_some_and(|a| {
913 a.get(&al.argument)
914 .is_some_and(serde_json::Value::is_string)
915 })
916 })
917 .map(|al| al.argument.as_str())
918 }
919
920 fn host_matches(patterns: &[String], host: &str) -> bool {
940 let host_lower = patterns
944 .iter()
945 .any(|p| p.contains('*'))
946 .then(|| host.to_ascii_lowercase());
947 patterns.iter().any(|p| {
948 if p.contains('*') {
949 host_lower
950 .as_deref()
951 .is_some_and(|h| glob_match(&p.to_ascii_lowercase(), h))
952 } else {
953 p.eq_ignore_ascii_case(host)
954 }
955 })
956 }
957
958 #[must_use]
967 pub fn redact_arg(&self, value: &str) -> String {
968 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
969 }
970}
971
972fn warn_on_literal_allow_globs(roles: &[RoleConfig], mode: AllowOperationMatching) {
980 match mode {
981 AllowOperationMatching::Glob => return,
982 AllowOperationMatching::Legacy => {}
983 }
984 for role in roles {
985 for entry in role.allow.iter().filter(|a| *a != "*" && a.contains('*')) {
986 tracing::warn!(
987 role = %role.name,
988 operation = %entry,
989 "allow entry contains '*' but operation matching is 'legacy'; \
990 the '*' is matched literally, not as a pattern -- set \
991 rbac.allow_operation_matching = \"glob\" to enable globbing, \
992 or list the operation names exactly"
993 );
994 }
995 }
996}
997
998fn warn_on_inert_global_deny(config: &RbacConfig) {
1000 if !config.enabled && !config.global_deny.is_empty() {
1001 tracing::warn!(
1002 patterns = config.global_deny.len(),
1003 "rbac.global_deny is configured but rbac.enabled is false; \
1004 the kill switch is inert because all checks short-circuit to allow"
1005 );
1006 }
1007}
1008
1009fn warn_on_optional_value_allowlists(roles: &[RoleConfig]) {
1010 for role in roles {
1011 for allowlist in &role.argument_allowlists {
1012 if !allowlist.allowed.is_empty() && !allowlist.required {
1013 tracing::warn!(
1014 role = %role.name,
1015 tool = %allowlist.tool,
1016 argument = %allowlist.argument,
1017 "optional argument allowlist may fail open"
1018 );
1019 }
1020 }
1021 }
1022}
1023
1024fn process_redaction_salt() -> &'static SecretString {
1027 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
1028 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
1029 PROCESS_SALT.get_or_init(|| {
1030 let mut bytes = [0u8; 32];
1031 rand::fill(&mut bytes);
1032 SecretString::from(STANDARD_NO_PAD.encode(bytes))
1035 })
1036}
1037
1038fn redact_with_salt(salt: &[u8], value: &str) -> String {
1043 use std::fmt::Write as _;
1044
1045 use sha2::Digest as _;
1046
1047 type HmacSha256 = Hmac<Sha256>;
1048 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
1054 m
1055 } else {
1056 let digest = Sha256::digest(salt);
1057 #[allow(
1058 clippy::expect_used,
1059 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
1060 )]
1061 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
1062 };
1063 mac.update(value.as_bytes());
1064 let bytes = mac.finalize().into_bytes();
1065 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
1067 let mut out = String::with_capacity(8);
1068 for b in prefix {
1069 let _ = write!(out, "{b:02x}");
1070 }
1071 out
1072}
1073
1074#[allow(
1095 clippy::too_many_lines,
1096 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
1097)]
1098pub(crate) async fn rbac_middleware(
1102 policy: Arc<RbacPolicy>,
1103 tool_limiter: Option<Arc<ToolRateLimiter>>,
1104 req: Request<Body>,
1105 next: Next,
1106) -> Response {
1107 if req.method() != Method::POST {
1109 return next.run(req).await;
1110 }
1111
1112 let peer_key = tool_limiter
1118 .is_some()
1119 .then(|| crate::transport::limiter_client_key(req.extensions()));
1120
1121 let identity = req.extensions().get::<AuthIdentity>();
1123 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
1124 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
1125 let raw_token: SecretString = identity
1128 .and_then(|id| id.raw_token.clone())
1129 .unwrap_or_else(|| SecretString::from(String::new()));
1130 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
1131
1132 if policy.is_enabled() && identity.is_none() {
1134 return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
1135 }
1136
1137 let (parts, body) = req.into_parts();
1139 let bytes = match body.collect().await {
1140 Ok(collected) => collected.to_bytes(),
1141 Err(e) => {
1142 tracing::error!(error = %e, "failed to read request body");
1143 return (
1144 StatusCode::INTERNAL_SERVER_ERROR,
1145 "failed to read request body",
1146 )
1147 .into_response();
1148 }
1149 };
1150
1151 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
1153 let tool_calls = extract_tool_calls(&json);
1154 if !tool_calls.is_empty() {
1155 for params in tool_calls {
1156 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_key.as_ref()) {
1157 #[cfg(feature = "metrics")]
1158 crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
1159 return resp;
1160 }
1161 if policy.is_enabled()
1162 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
1163 {
1164 return resp;
1165 }
1166 }
1167 }
1168 }
1169 let req = Request::from_parts(parts, Body::from(bytes));
1173
1174 if role.is_empty() {
1176 next.run(req).await
1177 } else {
1178 CURRENT_ROLE
1179 .scope(
1180 role,
1181 CURRENT_IDENTITY.scope(
1182 identity_name,
1183 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
1184 ),
1185 )
1186 .await
1187 }
1188}
1189
1190fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
1196 match value {
1197 serde_json::Value::Object(map) => map
1198 .get("method")
1199 .and_then(serde_json::Value::as_str)
1200 .filter(|method| *method == "tools/call")
1201 .and_then(|_| map.get("params"))
1202 .into_iter()
1203 .collect(),
1204 serde_json::Value::Array(items) => items
1205 .iter()
1206 .filter_map(|item| match item {
1207 serde_json::Value::Object(map) => map
1208 .get("method")
1209 .and_then(serde_json::Value::as_str)
1210 .filter(|method| *method == "tools/call")
1211 .and_then(|_| map.get("params")),
1212 serde_json::Value::Null
1213 | serde_json::Value::Bool(_)
1214 | serde_json::Value::Number(_)
1215 | serde_json::Value::String(_)
1216 | serde_json::Value::Array(_) => None,
1217 })
1218 .collect(),
1219 serde_json::Value::Null
1220 | serde_json::Value::Bool(_)
1221 | serde_json::Value::Number(_)
1222 | serde_json::Value::String(_) => Vec::new(),
1223 }
1224}
1225
1226fn enforce_rate_limit(
1229 tool_limiter: Option<&ToolRateLimiter>,
1230 peer_key: Option<&crate::transport::RateLimitKey>,
1231) -> Option<Response> {
1232 let limiter = tool_limiter?;
1233 let key = peer_key?;
1234 match limiter.check_key_detailed(key) {
1235 Ok(()) => None,
1236 Err(BoundedLimiterDeny::RateLimited(wait)) => {
1237 tracing::warn!(rate_limit_key = %key, "tool invocation rate limited");
1238 Some(
1239 RmcpServerKitError::RateLimitedFor {
1240 message: "too many tool invocations".into(),
1241 retry_after: wait,
1242 }
1243 .into_response(),
1244 )
1245 }
1246 Err(BoundedLimiterDeny::CapacityFull) => {
1247 tracing::warn!(
1248 rate_limit_key = %key,
1249 "tool invocation limiter rejected unseen key because tracked-key capacity is full"
1250 );
1251 Some(
1252 (
1253 StatusCode::SERVICE_UNAVAILABLE,
1254 "rate limiter capacity exhausted",
1255 )
1256 .into_response(),
1257 )
1258 }
1259 }
1260}
1261
1262fn enforce_tool_policy(
1271 policy: &RbacPolicy,
1272 identity_name: &str,
1273 role: &str,
1274 params: &serde_json::Value,
1275) -> Option<Response> {
1276 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
1277 let host_value = params.get("arguments").and_then(|a| a.get("host"));
1278
1279 if let Some(value) = host_value
1287 && !value.is_string()
1288 {
1289 tracing::warn!(
1290 user = %identity_name,
1291 role = %role,
1292 tool = tool_name,
1293 value_type = json_value_type(value),
1294 "non-string host argument rejected"
1295 );
1296 return Some(
1297 RmcpServerKitError::Rbac(format!(
1298 "argument 'host' must be a string for tool '{tool_name}'"
1299 ))
1300 .into_response(),
1301 );
1302 }
1303 let host = host_value.and_then(|h| h.as_str());
1306
1307 let decision = if let Some(host) = host {
1308 policy.check(role, tool_name, host)
1309 } else {
1310 policy.check_operation(role, tool_name)
1311 };
1312 if decision == RbacDecision::Deny {
1313 tracing::warn!(
1314 user = %identity_name,
1315 role = %role,
1316 tool = tool_name,
1317 host = host.unwrap_or("-"),
1318 "RBAC denied"
1319 );
1320 return Some(
1321 RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
1322 .into_response(),
1323 );
1324 }
1325
1326 let args = params.get("arguments").and_then(|a| a.as_object());
1327 let strict = policy.strict_argument_names(role, tool_name);
1328 if let Some(args) = args {
1329 for (arg_key, arg_val) in args {
1330 if let Some(ref permitted) = strict
1331 && let Some(resp) = check_strict_argument(
1332 identity_name,
1333 role,
1334 tool_name,
1335 permitted,
1336 arg_key,
1337 arg_val,
1338 )
1339 {
1340 return Some(resp);
1341 }
1342 if let Some(resp) =
1343 check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
1344 {
1345 return Some(resp);
1346 }
1347 }
1348 }
1349 check_required_arguments(policy, identity_name, role, tool_name, args)
1350}
1351
1352fn check_strict_argument(
1357 identity_name: &str,
1358 role: &str,
1359 tool_name: &str,
1360 permitted: &[&str],
1361 arg_key: &str,
1362 arg_val: &serde_json::Value,
1363) -> Option<Response> {
1364 if !permitted.contains(&arg_key) {
1365 tracing::warn!(
1366 user = %identity_name,
1367 role = %role,
1368 tool = tool_name,
1369 argument = arg_key,
1370 "unknown argument rejected by strict allowlist"
1371 );
1372 return Some(
1373 RmcpServerKitError::Rbac(format!(
1374 "argument '{arg_key}' is not permitted for tool '{tool_name}'"
1375 ))
1376 .into_response(),
1377 );
1378 }
1379 if arg_val.is_object() || arg_val.is_array() {
1380 tracing::warn!(
1381 user = %identity_name,
1382 role = %role,
1383 tool = tool_name,
1384 argument = arg_key,
1385 value_type = json_value_type(arg_val),
1386 "structured argument rejected by strict allowlist"
1387 );
1388 return Some(
1389 RmcpServerKitError::Rbac(format!(
1390 "argument '{arg_key}' must not be an object or array for tool '{tool_name}'"
1391 ))
1392 .into_response(),
1393 );
1394 }
1395 None
1396}
1397
1398fn check_required_arguments(
1406 policy: &RbacPolicy,
1407 identity_name: &str,
1408 role: &str,
1409 tool_name: &str,
1410 args: Option<&serde_json::Map<String, serde_json::Value>>,
1411) -> Option<Response> {
1412 let missing = policy.missing_required_argument(role, tool_name, args)?;
1413 tracing::warn!(
1414 user = %identity_name,
1415 role = %role,
1416 tool = tool_name,
1417 argument = missing,
1418 "required argument missing"
1419 );
1420 Some(
1421 RmcpServerKitError::Rbac(format!(
1422 "argument '{missing}' is required for tool '{tool_name}'"
1423 ))
1424 .into_response(),
1425 )
1426}
1427
1428fn check_argument(
1429 policy: &RbacPolicy,
1430 identity_name: &str,
1431 role: &str,
1432 tool_name: &str,
1433 arg_key: &str,
1434 arg_val: &serde_json::Value,
1435) -> Option<Response> {
1436 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1437 return None;
1438 }
1439 let Some(val_str) = arg_val.as_str() else {
1440 tracing::warn!(
1446 user = %identity_name,
1447 role = %role,
1448 tool = tool_name,
1449 argument = arg_key,
1450 value_type = json_value_type(arg_val),
1451 "non-string argument rejected by allowlist"
1452 );
1453 return Some(
1454 RmcpServerKitError::Rbac(format!(
1455 "argument '{arg_key}' must be a string for tool '{tool_name}'"
1456 ))
1457 .into_response(),
1458 );
1459 };
1460 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1461 return None;
1462 }
1463 tracing::warn!(
1468 user = %identity_name,
1469 role = %role,
1470 tool = tool_name,
1471 argument = arg_key,
1472 arg_hmac = %policy.redact_arg(val_str),
1473 "argument not in allowlist"
1474 );
1475 Some(
1476 RmcpServerKitError::Rbac(format!(
1477 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1478 ))
1479 .into_response(),
1480 )
1481}
1482
1483fn json_value_type(v: &serde_json::Value) -> &'static str {
1484 match v {
1485 serde_json::Value::Null => "null",
1486 serde_json::Value::Bool(_) => "bool",
1487 serde_json::Value::Number(_) => "number",
1488 serde_json::Value::String(_) => "string",
1489 serde_json::Value::Array(_) => "array",
1490 serde_json::Value::Object(_) => "object",
1491 }
1492}
1493
1494fn glob_match(pattern: &str, text: &str) -> bool {
1504 let parts: Vec<&str> = pattern.split('*').collect();
1505 if parts.len() == 1 {
1506 return pattern == text;
1508 }
1509
1510 let pos = if let Some(&first) = parts.first()
1512 && !first.is_empty()
1513 {
1514 if !text.starts_with(first) {
1515 return false;
1516 }
1517 first.len()
1518 } else {
1519 0
1520 };
1521
1522 if let Some(&last) = parts.last()
1524 && !last.is_empty()
1525 {
1526 if !text.get(pos..).unwrap_or_default().ends_with(last) {
1527 return false;
1528 }
1529 let end = text.len() - last.len();
1531 if pos > end {
1532 return false;
1533 }
1534 let middle = text.get(pos..end).unwrap_or_default();
1536 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1537 return match_middle(middle, middle_parts);
1538 }
1539
1540 let middle = text.get(pos..).unwrap_or_default();
1542 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1543 match_middle(middle, middle_parts)
1544}
1545
1546fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1548 for part in parts {
1549 if part.is_empty() {
1550 continue;
1551 }
1552 if let Some(idx) = text.find(part) {
1553 text = text.get(idx + part.len()..).unwrap_or_default();
1554 } else {
1555 return false;
1556 }
1557 }
1558 true
1559}
1560
1561impl RbacConfig {
1562 pub fn apply_env_overrides(
1595 &mut self,
1596 ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1597 let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1598 let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1599 match (direct, file) {
1600 (None, None) => Ok(Vec::new()),
1601 (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1602 "{} and {} must not both be set",
1603 crate::config::RBAC_REDACTION_SALT_ENV,
1604 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1605 ))),
1606 (Some(value), None) => {
1607 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1608 self.redaction_salt = Some(SecretString::from(value));
1609 Ok(vec![crate::config::secret_env_report(
1610 crate::config::RBAC_REDACTION_SALT_ENV,
1611 "rbac.redaction_salt",
1612 crate::config::EnvOverrideSource::Env,
1613 )])
1614 }
1615 (None, Some(path)) => {
1616 let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1617 RmcpServerKitError::Config(format!(
1618 "failed to read {} file {path:?}: {error}",
1619 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1620 ))
1621 })?;
1622 let secret = crate::config::normalize_text_secret_file(secret);
1623 reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1624 self.redaction_salt = Some(SecretString::from(secret));
1625 Ok(vec![crate::config::secret_env_report(
1626 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1627 "rbac.redaction_salt",
1628 crate::config::EnvOverrideSource::File,
1629 )])
1630 }
1631 }
1632 }
1633}
1634
1635fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1636 if value.trim().is_empty() {
1637 return Err(RmcpServerKitError::Config(format!(
1638 "{env_var} must not be empty or whitespace-only"
1639 )));
1640 }
1641 Ok(())
1642}
1643
1644#[cfg(test)]
1645mod tests {
1646 use std::net::IpAddr;
1647
1648 use super::*;
1649 use crate::transport::RateLimitKey;
1650
1651 fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1652 temp_env::with_vars(
1653 [
1654 (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1655 (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1656 ]
1657 .into_iter()
1658 .chain(vars.iter().copied())
1659 .collect::<Vec<_>>(),
1660 f,
1661 )
1662 }
1663
1664 #[test]
1665 fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1666 with_rbac_env(
1667 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1668 || {
1669 let mut cfg = RbacConfig::default();
1670 let report = cfg.apply_env_overrides().unwrap();
1671 assert!(cfg.redaction_salt.is_some());
1672 assert_eq!(report.len(), 1);
1673 assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1674 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1675 assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1676 assert!(report[0].value.is_none());
1677 assert!(!format!("{report:?}").contains("s3cret"));
1678 },
1679 );
1680 }
1681
1682 #[test]
1683 fn e7_redaction_salt_value_and_file_conflict_fails() {
1684 with_rbac_env(
1685 &[
1686 (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1687 (
1688 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1689 Some("/tmp/secret-file"),
1690 ),
1691 ],
1692 || {
1693 let mut cfg = RbacConfig::default();
1694 let err = cfg.apply_env_overrides().unwrap_err();
1695 let msg = err.to_string();
1696 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1697 assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1698 },
1699 );
1700 }
1701
1702 #[test]
1703 fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1704 let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1705 let direct_redaction = redaction_from_direct_salt("same-salt");
1706
1707 assert_eq!(file_redaction, direct_redaction);
1708 assert_eq!(report.len(), 1);
1709 assert_eq!(
1710 report[0].env_var,
1711 crate::config::RBAC_REDACTION_SALT_FILE_ENV
1712 );
1713 assert_eq!(report[0].target_field, "rbac.redaction_salt");
1714 assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1715 assert!(report[0].value.is_none());
1716 }
1717
1718 #[test]
1719 fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1720 let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1721 assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1722
1723 let (spaced_redaction, _) = redaction_from_file(" same-salt \n").expect("spaced salt");
1724 assert_eq!(
1725 spaced_redaction,
1726 redaction_from_direct_salt(" same-salt ")
1727 );
1728 assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1729 }
1730
1731 #[derive(Clone, Default)]
1732 struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
1733
1734 impl CapturedLogs {
1735 fn contents(&self) -> String {
1736 let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
1737 String::from_utf8(bytes).unwrap_or_default()
1738 }
1739 }
1740
1741 struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
1742
1743 impl std::io::Write for CapturedLogsWriter {
1744 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1745 if let Ok(mut guard) = self.0.lock() {
1746 guard.extend_from_slice(buf);
1747 }
1748 Ok(buf.len())
1749 }
1750
1751 fn flush(&mut self) -> std::io::Result<()> {
1752 Ok(())
1753 }
1754 }
1755
1756 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
1757 type Writer = CapturedLogsWriter;
1758
1759 fn make_writer(&'a self) -> Self::Writer {
1760 CapturedLogsWriter(Arc::clone(&self.0))
1761 }
1762 }
1763
1764 fn allowlist_warning_policy(allowlist: ArgumentAllowlist) -> RbacConfig {
1765 RbacConfig::with_roles(vec![
1766 RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1767 .with_argument_allowlists(vec![allowlist]),
1768 ])
1769 }
1770
1771 fn capture_policy_construction_logs(config: &RbacConfig) -> String {
1772 let logs = CapturedLogs::default();
1773 let subscriber = tracing_subscriber::fmt()
1774 .with_writer(logs.clone())
1775 .with_ansi(false)
1776 .without_time()
1777 .finish();
1778 let _guard = tracing::subscriber::set_default(subscriber);
1779
1780 let _policy = RbacPolicy::new(config);
1781 logs.contents()
1782 }
1783
1784 #[test]
1785 fn optional_non_empty_argument_allowlist_warns_once_at_policy_construction() {
1786 let config =
1787 allowlist_warning_policy(ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]));
1788
1789 let logs = capture_policy_construction_logs(&config);
1790
1791 assert_eq!(
1792 logs.matches("optional argument allowlist may fail open")
1793 .count(),
1794 1,
1795 "exactly one warning expected for one optional non-empty allowlist: {logs}"
1796 );
1797 assert!(logs.contains("run"), "warning must name the tool: {logs}");
1798 assert!(
1799 logs.contains("cmd"),
1800 "warning must name the argument: {logs}"
1801 );
1802 }
1803
1804 #[test]
1805 fn required_argument_allowlist_does_not_warn_at_policy_construction() {
1806 let config = allowlist_warning_policy(
1807 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]).with_required(true),
1808 );
1809
1810 let logs = capture_policy_construction_logs(&config);
1811
1812 assert!(
1813 !logs.contains("optional argument allowlist may fail open"),
1814 "required allowlist must not warn: {logs}"
1815 );
1816 }
1817
1818 #[test]
1819 fn new_required_sets_required_and_preserves_value_allowlist_behavior() {
1820 let optional = ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]);
1821 let required = ArgumentAllowlist::new_required("run", "cmd", vec!["ls".into()]);
1822
1823 assert_eq!(required.tool, optional.tool);
1824 assert_eq!(required.argument, optional.argument);
1825 assert_eq!(required.allowed, optional.allowed);
1826 assert!(required.required);
1827 assert!(!optional.required);
1828
1829 let optional_policy = RbacPolicy::new(&allowlist_warning_policy(optional));
1830 let required_policy = RbacPolicy::new(&allowlist_warning_policy(required));
1831 assert_eq!(
1832 optional_policy.argument_allowed("viewer", "run", "cmd", "ls -la"),
1833 required_policy.argument_allowed("viewer", "run", "cmd", "ls -la")
1834 );
1835 assert_eq!(
1836 optional_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /"),
1837 required_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /")
1838 );
1839 }
1840
1841 #[test]
1842 fn blank_redaction_salt_env_values_fail_closed() {
1843 for value in ["", "\n", " "] {
1844 with_rbac_env(
1845 &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1846 || {
1847 let mut cfg = RbacConfig::default();
1848 let err = cfg.apply_env_overrides().unwrap_err();
1849 assert!(
1850 err.to_string()
1851 .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1852 );
1853 },
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn blank_redaction_salt_file_values_fail_closed() {
1860 for value in ["", "\n", "\r\n", " \n"] {
1861 let err = redaction_from_file(value).unwrap_err();
1862 assert!(
1863 err.to_string()
1864 .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1865 );
1866 }
1867 }
1868
1869 fn redaction_from_direct_salt(salt: &str) -> String {
1870 RbacPolicy::new(&RbacConfig {
1871 redaction_salt: Some(SecretString::from(salt.to_owned())),
1872 ..RbacConfig::default()
1873 })
1874 .redact_arg("same-argument")
1875 }
1876
1877 fn redaction_from_file(
1878 content: &str,
1879 ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1880 let path = std::env::temp_dir().join(format!(
1881 "rmcp-server-kit-redaction-salt-{}.txt",
1882 std::time::SystemTime::now()
1883 .duration_since(std::time::UNIX_EPOCH)
1884 .expect("clock after epoch")
1885 .as_nanos()
1886 ));
1887 std::fs::write(&path, content).expect("write salt file");
1888 let path_string = path.to_string_lossy().to_string();
1889 let result = with_rbac_env(
1890 &[(
1891 crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1892 Some(path_string.as_str()),
1893 )],
1894 || {
1895 let mut cfg = RbacConfig::default();
1896 let report = cfg.apply_env_overrides()?;
1897 let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1898 Ok((redaction, report))
1899 },
1900 );
1901 std::fs::remove_file(path).expect("remove salt file");
1902 result
1903 }
1904
1905 #[test]
1910 fn tool_limiter_burst_allows_initial_spike() {
1911 let limiter = build_tool_rate_limiter_with_policy(2, Some(4), KeyEvictionPolicy::default());
1912 let ip = RateLimitKey::Ip("10.9.9.9".parse::<IpAddr>().unwrap());
1913 for i in 0..4 {
1914 assert!(
1915 limiter.check_key(&ip).is_ok(),
1916 "burst request {i} should pass"
1917 );
1918 }
1919 assert!(
1920 limiter.check_key(&ip).is_err(),
1921 "request 5 must exceed the burst bucket"
1922 );
1923 }
1924
1925 #[test]
1927 fn tool_limiter_deny_sets_retry_after() {
1928 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
1929 let ip = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1930 assert!(enforce_rate_limit(Some(&limiter), Some(&ip)).is_none());
1931 let resp = enforce_rate_limit(Some(&limiter), Some(&ip))
1932 .expect("second call within the window must deny");
1933 assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1934 let retry_after = resp
1935 .headers()
1936 .get(axum::http::header::RETRY_AFTER)
1937 .expect("Retry-After present")
1938 .to_str()
1939 .unwrap()
1940 .parse::<u64>()
1941 .unwrap();
1942 assert!(retry_after >= 1, "delta-seconds must be >= 1");
1943 }
1944
1945 #[test]
1946 fn tool_limiter_capacity_full_returns_503_without_retry_after() {
1947 let limiter = build_tool_rate_limiter_with_bounds(
1948 10,
1949 None,
1950 1,
1951 Duration::from_hours(1),
1952 KeyEvictionPolicy::RejectNew,
1953 );
1954 let established = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1955 let unseen = RateLimitKey::Ip("10.8.8.9".parse::<IpAddr>().unwrap());
1956 assert!(enforce_rate_limit(Some(&limiter), Some(&established)).is_none());
1957
1958 let resp = enforce_rate_limit(Some(&limiter), Some(&unseen))
1959 .expect("unseen key must be rejected at capacity");
1960
1961 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
1962 assert!(
1963 resp.headers()
1964 .get(axum::http::header::RETRY_AFTER)
1965 .is_none()
1966 );
1967 }
1968
1969 fn test_policy() -> RbacPolicy {
1970 RbacPolicy::new(&RbacConfig {
1971 enabled: true,
1972 roles: vec![
1973 RoleConfig {
1974 name: "viewer".into(),
1975 description: Some("Read-only".into()),
1976 allow: vec![
1977 "list_hosts".into(),
1978 "resource_list".into(),
1979 "resource_inspect".into(),
1980 "resource_logs".into(),
1981 "system_info".into(),
1982 ],
1983 deny: vec![],
1984 hosts: vec!["*".into()],
1985 argument_allowlists: vec![],
1986 },
1987 RoleConfig {
1988 name: "deploy".into(),
1989 description: Some("Lifecycle management".into()),
1990 allow: vec![
1991 "list_hosts".into(),
1992 "resource_list".into(),
1993 "resource_run".into(),
1994 "resource_start".into(),
1995 "resource_stop".into(),
1996 "resource_restart".into(),
1997 "resource_logs".into(),
1998 "image_pull".into(),
1999 ],
2000 deny: vec!["resource_delete".into(), "resource_exec".into()],
2001 hosts: vec!["web-*".into(), "api-*".into()],
2002 argument_allowlists: vec![],
2003 },
2004 RoleConfig {
2005 name: "ops".into(),
2006 description: Some("Full access".into()),
2007 allow: vec!["*".into()],
2008 deny: vec![],
2009 hosts: vec!["*".into()],
2010 argument_allowlists: vec![],
2011 },
2012 RoleConfig {
2013 name: "restricted-exec".into(),
2014 description: Some("Exec with argument allowlist".into()),
2015 allow: vec!["resource_exec".into()],
2016 deny: vec![],
2017 hosts: vec!["dev-*".into()],
2018 argument_allowlists: vec![ArgumentAllowlist {
2019 tool: "resource_exec".into(),
2020 argument: "cmd".into(),
2021 allowed: vec![
2022 "sh".into(),
2023 "bash".into(),
2024 "cat".into(),
2025 "ls".into(),
2026 "ps".into(),
2027 ],
2028 required: false,
2029 deny_unknown_arguments: false,
2030 }],
2031 },
2032 ],
2033 redaction_salt: None,
2034 ..RbacConfig::default()
2035 })
2036 }
2037
2038 #[test]
2041 fn glob_exact_match() {
2042 assert!(glob_match("web-prod-1", "web-prod-1"));
2043 assert!(!glob_match("web-prod-1", "web-prod-2"));
2044 }
2045
2046 #[test]
2047 fn glob_star_suffix() {
2048 assert!(glob_match("web-*", "web-prod-1"));
2049 assert!(glob_match("web-*", "web-staging"));
2050 assert!(!glob_match("web-*", "api-prod"));
2051 }
2052
2053 #[test]
2054 fn glob_star_prefix() {
2055 assert!(glob_match("*-prod", "web-prod"));
2056 assert!(glob_match("*-prod", "api-prod"));
2057 assert!(!glob_match("*-prod", "web-staging"));
2058 }
2059
2060 #[test]
2061 fn glob_star_middle() {
2062 assert!(glob_match("web-*-prod", "web-us-prod"));
2063 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
2064 assert!(!glob_match("web-*-prod", "web-staging"));
2065 }
2066
2067 #[test]
2068 fn glob_star_only() {
2069 assert!(glob_match("*", "anything"));
2070 assert!(glob_match("*", ""));
2071 }
2072
2073 #[test]
2074 fn glob_multiple_stars() {
2075 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
2076 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
2077 }
2078
2079 #[test]
2084 fn glob_match_multibyte_utf8() {
2085 assert!(glob_match("hé*llo", "héllo"));
2086 assert!(glob_match("*ö*", "wörld"));
2087 assert!(glob_match("über*", "übermensch"));
2088 assert!(glob_match("*界", "世界"));
2089 assert!(!glob_match("hé*llo", "hello"));
2090 assert!(!glob_match("界*", "世界"));
2091 assert!(glob_match("世*界", "世界"));
2092 }
2093
2094 #[test]
2106 fn glob_prefix_and_suffix_meet_exactly() {
2107 assert!(glob_match("ab*cd", "abcd"));
2110 }
2111
2112 #[test]
2117 fn glob_middle_segment_required_with_suffix() {
2118 assert!(!glob_match("a*b*c", "axyc"));
2123 }
2124
2125 #[test]
2131 fn glob_match_middle_advances_past_matched_part() {
2132 assert!(!glob_match("*ab*ab*", "xxab_yz"));
2137 }
2138
2139 #[test]
2144 fn glob_match_middle_uses_addition_not_multiplication() {
2145 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
2149 }
2150
2151 #[test]
2160 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
2161 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
2169 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2170 "run-*",
2171 "cmd",
2172 vec!["ls".into()],
2173 )]);
2174 let mut config = RbacConfig::with_roles(vec![role]);
2175 config.enabled = true;
2176 let policy = RbacPolicy::new(&config);
2177 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
2178 }
2179
2180 #[test]
2183 fn disabled_policy_allows_everything() {
2184 let policy = RbacPolicy::new(&RbacConfig {
2185 enabled: false,
2186 roles: vec![],
2187 redaction_salt: None,
2188 ..RbacConfig::default()
2189 });
2190 assert_eq!(
2191 policy.check("nonexistent", "resource_delete", "any-host"),
2192 RbacDecision::Allow
2193 );
2194 }
2195
2196 #[test]
2197 fn unknown_role_denied() {
2198 let policy = test_policy();
2199 assert_eq!(
2200 policy.check("unknown", "resource_list", "web-prod-1"),
2201 RbacDecision::Deny
2202 );
2203 }
2204
2205 #[test]
2206 fn viewer_allowed_read_ops() {
2207 let policy = test_policy();
2208 assert_eq!(
2209 policy.check("viewer", "resource_list", "web-prod-1"),
2210 RbacDecision::Allow
2211 );
2212 assert_eq!(
2213 policy.check("viewer", "system_info", "db-host"),
2214 RbacDecision::Allow
2215 );
2216 }
2217
2218 #[test]
2219 fn viewer_denied_write_ops() {
2220 let policy = test_policy();
2221 assert_eq!(
2222 policy.check("viewer", "resource_run", "web-prod-1"),
2223 RbacDecision::Deny
2224 );
2225 assert_eq!(
2226 policy.check("viewer", "resource_delete", "web-prod-1"),
2227 RbacDecision::Deny
2228 );
2229 }
2230
2231 #[test]
2232 fn deploy_allowed_on_matching_hosts() {
2233 let policy = test_policy();
2234 assert_eq!(
2235 policy.check("deploy", "resource_run", "web-prod-1"),
2236 RbacDecision::Allow
2237 );
2238 assert_eq!(
2239 policy.check("deploy", "resource_start", "api-staging"),
2240 RbacDecision::Allow
2241 );
2242 }
2243
2244 #[test]
2245 fn deploy_denied_on_non_matching_host() {
2246 let policy = test_policy();
2247 assert_eq!(
2248 policy.check("deploy", "resource_run", "db-prod-1"),
2249 RbacDecision::Deny
2250 );
2251 }
2252
2253 #[test]
2254 fn deny_overrides_allow() {
2255 let policy = test_policy();
2256 assert_eq!(
2257 policy.check("deploy", "resource_delete", "web-prod-1"),
2258 RbacDecision::Deny
2259 );
2260 assert_eq!(
2261 policy.check("deploy", "resource_exec", "web-prod-1"),
2262 RbacDecision::Deny
2263 );
2264 }
2265
2266 #[test]
2267 fn ops_wildcard_allows_everything() {
2268 let policy = test_policy();
2269 assert_eq!(
2270 policy.check("ops", "resource_delete", "any-host"),
2271 RbacDecision::Allow
2272 );
2273 assert_eq!(
2274 policy.check("ops", "secret_create", "db-host"),
2275 RbacDecision::Allow
2276 );
2277 }
2278
2279 #[test]
2282 fn host_visible_respects_globs() {
2283 let policy = test_policy();
2284 assert!(policy.host_visible("deploy", "web-prod-1"));
2285 assert!(policy.host_visible("deploy", "api-staging"));
2286 assert!(!policy.host_visible("deploy", "db-prod-1"));
2287 assert!(policy.host_visible("ops", "anything"));
2288 assert!(policy.host_visible("viewer", "anything"));
2289 }
2290
2291 #[test]
2292 fn host_visible_unknown_role() {
2293 let policy = test_policy();
2294 assert!(!policy.host_visible("unknown", "web-prod-1"));
2295 }
2296
2297 #[test]
2298 fn host_matching_is_ascii_case_insensitive() {
2299 let policy = test_policy();
2300 assert!(policy.host_visible("deploy", "WEB-PROD-1"));
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 }
2305
2306 #[test]
2307 fn check_host_matching_is_ascii_case_insensitive() {
2308 let policy = test_policy();
2309 assert_eq!(
2310 policy.check("deploy", "resource_run", "WEB-PROD-1"),
2311 RbacDecision::Allow
2312 );
2313 assert_eq!(
2314 policy.check("deploy", "resource_run", "DB-PROD-1"),
2315 RbacDecision::Deny
2316 );
2317 }
2318
2319 #[test]
2320 fn check_operation_names_remain_case_sensitive() {
2321 let policy = test_policy();
2322 assert_eq!(
2323 policy.check("deploy", "RESOURCE_RUN", "web-prod-1"),
2324 RbacDecision::Deny,
2325 "host normalization must not leak into operation matching"
2326 );
2327 }
2328
2329 #[test]
2330 fn tool_glob_matching_remains_case_sensitive() {
2331 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2334 .with_argument_allowlists(vec![ArgumentAllowlist::new(
2335 "resource_*",
2336 "cmd",
2337 vec!["ls".into()],
2338 )]);
2339 let policy = RbacPolicy::new(&RbacConfig::with_roles(vec![role]));
2340
2341 assert!(policy.has_argument_allowlist("viewer", "resource_exec", "cmd"));
2342 assert!(
2343 !policy.has_argument_allowlist("viewer", "RESOURCE_EXEC", "cmd"),
2344 "tool patterns must not match case-insensitively"
2345 );
2346 assert!(!policy.argument_allowed("viewer", "resource_exec", "cmd", "rm"));
2347 }
2348
2349 #[test]
2352 fn argument_allowed_no_allowlist() {
2353 let policy = test_policy();
2354 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
2356 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
2357 }
2358
2359 #[test]
2360 fn argument_allowed_with_allowlist() {
2361 let policy = test_policy();
2362 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
2363 assert!(policy.argument_allowed(
2364 "restricted-exec",
2365 "resource_exec",
2366 "cmd",
2367 "bash -c 'echo hi'"
2368 ));
2369 assert!(policy.argument_allowed(
2370 "restricted-exec",
2371 "resource_exec",
2372 "cmd",
2373 "cat /etc/hosts"
2374 ));
2375 assert!(policy.argument_allowed(
2376 "restricted-exec",
2377 "resource_exec",
2378 "cmd",
2379 "/usr/bin/ls -la"
2380 ));
2381 }
2382
2383 #[test]
2384 fn argument_denied_not_in_allowlist() {
2385 let policy = test_policy();
2386 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
2387 assert!(!policy.argument_allowed(
2388 "restricted-exec",
2389 "resource_exec",
2390 "cmd",
2391 "python3 exploit.py"
2392 ));
2393 assert!(!policy.argument_allowed(
2394 "restricted-exec",
2395 "resource_exec",
2396 "cmd",
2397 "/usr/bin/curl evil.com"
2398 ));
2399 }
2400
2401 #[test]
2402 fn argument_denied_unknown_role() {
2403 let policy = test_policy();
2404 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
2405 }
2406
2407 fn strict_test_policy(allowlists: Vec<ArgumentAllowlist>) -> RbacPolicy {
2410 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2411 .with_argument_allowlists(allowlists);
2412 let mut config = RbacConfig::with_roles(vec![role]);
2413 config.enabled = true;
2414 RbacPolicy::new(&config)
2415 }
2416
2417 fn tool_call(args: serde_json::Value) -> serde_json::Value {
2418 let mut params = serde_json::Map::new();
2419 params.insert(
2420 "name".to_owned(),
2421 serde_json::Value::String("run".to_owned()),
2422 );
2423 params.insert("arguments".to_owned(), args);
2424 serde_json::Value::Object(params)
2425 }
2426
2427 #[test]
2428 fn unknown_arguments_are_admitted_when_strict_mode_is_off() {
2429 let policy = strict_test_policy(vec![ArgumentAllowlist::new(
2430 "run",
2431 "cmd",
2432 vec!["ls".into()],
2433 )]);
2434 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2435 assert!(
2436 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2437 "default behaviour must be unchanged: unnamed arguments pass"
2438 );
2439 }
2440
2441 #[test]
2442 fn strict_mode_rejects_unknown_arguments() {
2443 let policy = strict_test_policy(vec![
2444 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2445 .with_deny_unknown_arguments(true),
2446 ]);
2447 let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2448 assert!(
2449 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2450 "an argument no allowlist names must be denied under strict mode"
2451 );
2452
2453 let permitted = tool_call(serde_json::json!({ "cmd": "ls" }));
2454 assert!(
2455 enforce_tool_policy(&policy, "u", "viewer", &permitted).is_none(),
2456 "an allowlisted argument must still pass"
2457 );
2458 }
2459
2460 #[test]
2461 fn strict_mode_rejects_structured_argument_values() {
2462 let policy = strict_test_policy(vec![
2463 ArgumentAllowlist::new("run", "cmd", vec![]).with_deny_unknown_arguments(true),
2464 ]);
2465 for shape in [
2466 serde_json::json!({ "nested": "x" }),
2467 serde_json::json!(["x"]),
2468 ] {
2469 let params = tool_call(serde_json::json!({ "cmd": shape }));
2470 assert!(
2471 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_some(),
2472 "object/array values cannot be constrained and must be denied"
2473 );
2474 }
2475 }
2476
2477 #[test]
2478 fn strict_mode_permits_the_union_of_matching_allowlists() {
2479 let policy = strict_test_policy(vec![
2482 ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2483 .with_deny_unknown_arguments(true),
2484 ArgumentAllowlist::new("run", "host", vec![]),
2485 ]);
2486 let params = tool_call(serde_json::json!({ "cmd": "ls", "host": "dev-1" }));
2487 assert!(
2488 enforce_tool_policy(&policy, "u", "viewer", ¶ms).is_none(),
2489 "every matching allowlist's argument must remain permitted"
2490 );
2491 }
2492
2493 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
2502 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2503 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
2504 let mut config = RbacConfig::with_roles(vec![role]);
2505 config.enabled = true;
2506 RbacPolicy::new(&config)
2507 }
2508
2509 #[test]
2510 fn argument_allowed_matches_quoted_path_with_spaces() {
2511 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
2512 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2513 }
2514
2515 #[test]
2516 fn argument_allowed_matches_basename_of_quoted_path() {
2517 let policy = shlex_policy(vec!["my tool".into()]);
2518 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2519 }
2520
2521 #[test]
2522 fn argument_allowed_fails_closed_on_unbalanced_quote() {
2523 let policy = shlex_policy(vec!["unbalanced".into()]);
2524 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
2525 }
2526
2527 #[test]
2528 fn argument_allowed_fails_closed_on_empty_string() {
2529 let policy = shlex_policy(vec![String::new()]);
2530 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
2531 }
2532
2533 #[test]
2534 fn argument_allowed_handles_single_quoted_executable() {
2535 let policy = shlex_policy(vec!["/bin/sh".into()]);
2536 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
2537 }
2538
2539 #[test]
2540 fn argument_allowed_handles_tab_separator() {
2541 let policy = shlex_policy(vec!["ls".into()]);
2542 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
2543 }
2544
2545 #[test]
2546 fn argument_allowed_plain_token_unchanged() {
2547 let policy = shlex_policy(vec!["ls".into()]);
2548 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
2549 }
2550
2551 #[test]
2557 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
2558 let policy = shlex_policy(vec![String::new()]);
2562 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
2563 }
2564
2565 #[test]
2566 fn argument_allowed_quoted_literal_token_no_longer_matches() {
2567 let policy = shlex_policy(vec!["'bash'".into()]);
2573 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
2574 }
2575
2576 #[test]
2577 fn argument_allowed_backslash_literal_token_no_longer_matches() {
2578 let policy = shlex_policy(vec![r"foo\bar".into()]);
2583 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
2584 }
2585
2586 #[test]
2587 fn argument_allowed_windows_path_no_longer_matches() {
2588 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
2593 assert!(!policy.argument_allowed(
2594 "viewer",
2595 "run",
2596 "cmd",
2597 r"C:\Windows\System32\cmd.exe /c dir"
2598 ));
2599 }
2600
2601 #[test]
2604 fn host_patterns_returns_globs() {
2605 let policy = test_policy();
2606 assert_eq!(
2607 policy.host_patterns("deploy"),
2608 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
2609 );
2610 assert_eq!(
2611 policy.host_patterns("ops"),
2612 Some(vec!["*".to_owned()].as_slice())
2613 );
2614 assert!(policy.host_patterns("nonexistent").is_none());
2615 }
2616
2617 #[test]
2620 fn check_operation_allows_without_host() {
2621 let policy = test_policy();
2622 assert_eq!(
2623 policy.check_operation("deploy", "resource_run"),
2624 RbacDecision::Allow
2625 );
2626 assert_eq!(
2628 policy.check("deploy", "resource_run", "db-prod-1"),
2629 RbacDecision::Deny
2630 );
2631 }
2632
2633 #[test]
2634 fn check_operation_deny_overrides() {
2635 let policy = test_policy();
2636 assert_eq!(
2637 policy.check_operation("deploy", "resource_delete"),
2638 RbacDecision::Deny
2639 );
2640 }
2641
2642 #[test]
2643 fn check_operation_unknown_role() {
2644 let policy = test_policy();
2645 assert_eq!(
2646 policy.check_operation("unknown", "resource_list"),
2647 RbacDecision::Deny
2648 );
2649 }
2650
2651 #[test]
2652 fn check_operation_disabled() {
2653 let policy = RbacPolicy::new(&RbacConfig {
2654 enabled: false,
2655 roles: vec![],
2656 redaction_salt: None,
2657 ..RbacConfig::default()
2658 });
2659 assert_eq!(
2660 policy.check_operation("nonexistent", "anything"),
2661 RbacDecision::Allow
2662 );
2663 }
2664
2665 fn op_policy(role: RoleConfig) -> RbacPolicy {
2668 RbacPolicy::new(&RbacConfig::with_roles(vec![role]))
2669 }
2670
2671 fn glob_op_policy(role: RoleConfig) -> RbacPolicy {
2672 RbacPolicy::new(
2673 &RbacConfig::with_roles(vec![role])
2674 .with_allow_operation_matching(AllowOperationMatching::Glob),
2675 )
2676 }
2677
2678 #[test]
2679 fn deny_glob_blocks_under_allow_all() {
2680 let policy = op_policy(
2681 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2682 .with_deny(vec!["*_delete_*".into()]),
2683 );
2684 assert_eq!(
2685 policy.check_operation("editor", "jira_delete_issue"),
2686 RbacDecision::Deny
2687 );
2688 assert_eq!(
2689 policy.check_operation("editor", "confluence_delete_page"),
2690 RbacDecision::Deny
2691 );
2692 assert_eq!(
2693 policy.check_operation("editor", "jira_get_issue"),
2694 RbacDecision::Allow
2695 );
2696 }
2697
2698 #[test]
2699 fn deny_glob_blocks_in_host_scoped_check() {
2700 let policy = op_policy(
2701 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2702 .with_deny(vec!["jira_delete_*".into()]),
2703 );
2704 assert_eq!(
2705 policy.check("editor", "jira_delete_issue", "web-prod"),
2706 RbacDecision::Deny
2707 );
2708 assert_eq!(
2709 policy.check("editor", "jira_get_issue", "web-prod"),
2710 RbacDecision::Allow
2711 );
2712 }
2713
2714 #[test]
2715 fn deny_without_glob_still_matches_exactly() {
2716 let policy = op_policy(
2717 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2718 .with_deny(vec!["delete".into()]),
2719 );
2720 assert_eq!(
2721 policy.check_operation("editor", "delete"),
2722 RbacDecision::Deny
2723 );
2724 assert_eq!(
2725 policy.check_operation("editor", "delete_thing"),
2726 RbacDecision::Allow
2727 );
2728 assert_eq!(
2729 policy.check_operation("editor", "soft_delete"),
2730 RbacDecision::Allow
2731 );
2732 }
2733
2734 #[test]
2735 fn allow_glob_is_inert_in_legacy_mode() {
2736 let policy = op_policy(RoleConfig::new(
2737 "reader",
2738 vec!["jira_get_*".into()],
2739 vec!["*".into()],
2740 ));
2741 assert_eq!(
2742 policy.check_operation("reader", "jira_get_issue"),
2743 RbacDecision::Deny
2744 );
2745 assert_eq!(
2746 policy.check_operation("reader", "jira_get_*"),
2747 RbacDecision::Allow
2748 );
2749 }
2750
2751 #[test]
2752 fn allow_glob_is_honored_in_glob_mode() {
2753 let policy = glob_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::Allow
2761 );
2762 assert_eq!(
2763 policy.check_operation("reader", "confluence_get_page"),
2764 RbacDecision::Deny
2765 );
2766 }
2767
2768 #[test]
2769 fn allow_glob_mode_preserves_case_sensitivity() {
2770 let policy = glob_op_policy(RoleConfig::new(
2771 "reader",
2772 vec!["Jira_*".into()],
2773 vec!["*".into()],
2774 ));
2775 assert_eq!(
2776 policy.check_operation("reader", "jira_get_issue"),
2777 RbacDecision::Deny
2778 );
2779 assert_eq!(
2780 policy.check_operation("reader", "Jira_get_issue"),
2781 RbacDecision::Allow
2782 );
2783 }
2784
2785 #[test]
2786 fn allow_exact_entries_behave_identically_in_both_modes() {
2787 let role = RoleConfig::new(
2788 "reader",
2789 vec!["ping".into(), "list_hosts".into()],
2790 vec!["*".into()],
2791 );
2792 let legacy = op_policy(role.clone());
2793 let glob = glob_op_policy(role);
2794 for op in ["ping", "list_hosts", "delete", "pin", "pingg"] {
2795 assert_eq!(
2796 legacy.check_operation("reader", op),
2797 glob.check_operation("reader", op),
2798 "mode divergence on glob-free allow entry for {op}"
2799 );
2800 }
2801 }
2802
2803 #[test]
2804 fn allow_star_means_all_operations_in_both_modes() {
2805 let role = RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]);
2806 for policy in [op_policy(role.clone()), glob_op_policy(role)] {
2807 assert_eq!(
2808 policy.check_operation("admin", "anything_at_all"),
2809 RbacDecision::Allow
2810 );
2811 }
2812 }
2813
2814 #[test]
2815 fn global_deny_vetoes_allow_all() {
2816 let policy = RbacPolicy::new(
2817 &RbacConfig::with_roles(vec![RoleConfig::new(
2818 "admin",
2819 vec!["*".into()],
2820 vec!["*".into()],
2821 )])
2822 .with_global_deny(vec!["*_delete_*".into()]),
2823 );
2824 assert_eq!(
2825 policy.check_operation("admin", "jira_delete_issue"),
2826 RbacDecision::Deny
2827 );
2828 assert_eq!(
2829 policy.check("admin", "jira_delete_issue", "web-prod"),
2830 RbacDecision::Deny
2831 );
2832 assert_eq!(
2833 policy.check_operation("admin", "jira_get_issue"),
2834 RbacDecision::Allow
2835 );
2836 }
2837
2838 #[test]
2839 fn global_deny_globs_even_in_legacy_allow_mode() {
2840 let policy = RbacPolicy::new(
2841 &RbacConfig::with_roles(vec![RoleConfig::new(
2842 "admin",
2843 vec!["*".into()],
2844 vec!["*".into()],
2845 )])
2846 .with_allow_operation_matching(AllowOperationMatching::Legacy)
2847 .with_global_deny(vec!["danger_*".into()]),
2848 );
2849 assert_eq!(
2850 policy.check_operation("admin", "danger_wipe"),
2851 RbacDecision::Deny
2852 );
2853 }
2854
2855 #[test]
2856 fn global_deny_is_inert_when_rbac_disabled() {
2857 let policy = RbacPolicy::new(&RbacConfig {
2858 enabled: false,
2859 global_deny: vec!["*".into()],
2860 ..RbacConfig::default()
2861 });
2862 assert_eq!(
2863 policy.check_operation("anyone", "anything"),
2864 RbacDecision::Allow
2865 );
2866 }
2867
2868 #[test]
2869 fn global_deny_defaults_to_empty_and_changes_nothing() {
2870 let policy = op_policy(RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]));
2871 assert_eq!(
2872 policy.check_operation("admin", "jira_delete_issue"),
2873 RbacDecision::Allow
2874 );
2875 assert_eq!(policy.summary().global_deny, 0);
2876 }
2877
2878 #[test]
2879 fn empty_deny_entry_denies_only_the_empty_operation() {
2880 let policy = op_policy(
2881 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2882 .with_deny(vec![String::new()]),
2883 );
2884 assert_eq!(policy.check_operation("editor", ""), RbacDecision::Deny);
2885 assert_eq!(
2886 policy.check_operation("editor", "anything"),
2887 RbacDecision::Allow
2888 );
2889 }
2890
2891 #[test]
2892 fn empty_global_deny_entry_denies_only_the_empty_operation() {
2893 let policy = RbacPolicy::new(
2894 &RbacConfig::with_roles(vec![RoleConfig::new(
2895 "admin",
2896 vec!["*".into()],
2897 vec!["*".into()],
2898 )])
2899 .with_global_deny(vec![String::new()]),
2900 );
2901 assert_eq!(policy.check_operation("admin", ""), RbacDecision::Deny);
2902 assert_eq!(
2903 policy.check_operation("admin", "anything"),
2904 RbacDecision::Allow
2905 );
2906 }
2907
2908 #[test]
2909 fn star_deny_entry_denies_every_operation() {
2910 let policy = op_policy(
2911 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2912 .with_deny(vec!["*".into()]),
2913 );
2914 for op in ["", "ping", "jira_delete_issue"] {
2915 assert_eq!(policy.check_operation("editor", op), RbacDecision::Deny);
2916 assert_eq!(policy.check("editor", op, "web-prod"), RbacDecision::Deny);
2917 }
2918 }
2919
2920 #[test]
2921 fn star_global_deny_entry_denies_every_operation() {
2922 let policy = RbacPolicy::new(
2923 &RbacConfig::with_roles(vec![RoleConfig::new(
2924 "admin",
2925 vec!["*".into()],
2926 vec!["*".into()],
2927 )])
2928 .with_global_deny(vec!["*".into()]),
2929 );
2930 for op in ["", "ping", "jira_delete_issue"] {
2931 assert_eq!(policy.check_operation("admin", op), RbacDecision::Deny);
2932 }
2933 }
2934
2935 #[test]
2936 fn legacy_allow_matches_a_literal_star_in_an_operation_name() {
2937 let policy = op_policy(RoleConfig::new(
2938 "odd",
2939 vec!["weird_*_name".into()],
2940 vec!["*".into()],
2941 ));
2942 assert_eq!(
2943 policy.check_operation("odd", "weird_*_name"),
2944 RbacDecision::Allow
2945 );
2946 assert_eq!(
2947 policy.check_operation("odd", "weird_thing_name"),
2948 RbacDecision::Deny
2949 );
2950 }
2951
2952 #[test]
2953 fn deny_glob_matches_multibyte_operation_names() {
2954 let policy = op_policy(
2955 RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2956 .with_deny(vec!["削除_*".into()]),
2957 );
2958 assert_eq!(
2959 policy.check_operation("editor", "削除_ページ"),
2960 RbacDecision::Deny
2961 );
2962 assert_eq!(
2963 policy.check_operation("editor", "取得_ページ"),
2964 RbacDecision::Allow
2965 );
2966 }
2967
2968 #[test]
2969 fn operation_matching_fields_deserialize_from_toml() {
2970 let cfg: RbacConfig = toml::from_str(
2971 r#"
2972 enabled = true
2973 allow_operation_matching = "glob"
2974 global_deny = ["*_purge_*"]
2975
2976 [[roles]]
2977 name = "ops"
2978 allow = ["jira_*"]
2979 hosts = ["*"]
2980 "#,
2981 )
2982 .expect("config parses");
2983 assert_eq!(
2984 cfg.allow_operation_matching,
2985 AllowOperationMatching::Glob,
2986 "kebab-case wire value must map to the Glob variant"
2987 );
2988 assert_eq!(cfg.global_deny, vec!["*_purge_*".to_owned()]);
2989
2990 let policy = RbacPolicy::new(&cfg);
2991 assert_eq!(
2992 policy.check_operation("ops", "jira_get_issue"),
2993 RbacDecision::Allow
2994 );
2995 assert_eq!(
2996 policy.check_operation("ops", "jira_purge_project"),
2997 RbacDecision::Deny
2998 );
2999 }
3000
3001 #[test]
3002 fn operation_matching_defaults_to_legacy_when_absent_from_toml() {
3003 let cfg: RbacConfig = toml::from_str("enabled = true").expect("config parses");
3004 assert_eq!(cfg.allow_operation_matching, AllowOperationMatching::Legacy);
3005 assert!(cfg.global_deny.is_empty());
3006 }
3007
3008 #[test]
3011 fn current_role_returns_none_outside_scope() {
3012 assert!(current_role().is_none());
3013 }
3014
3015 #[test]
3016 fn current_identity_returns_none_outside_scope() {
3017 assert!(current_identity().is_none());
3018 }
3019
3020 use axum::{
3023 body::Body,
3024 http::{Method, Request, StatusCode},
3025 };
3026 use tower::ServiceExt as _;
3027
3028 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
3029 serde_json::json!({
3030 "jsonrpc": "2.0",
3031 "id": 1,
3032 "method": "tools/call",
3033 "params": {
3034 "name": tool,
3035 "arguments": args
3036 }
3037 })
3038 .to_string()
3039 }
3040
3041 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
3042 axum::Router::new()
3043 .route("/mcp", axum::routing::post(|| async { "ok" }))
3044 .layer(axum::middleware::from_fn(move |req, next| {
3045 let p = Arc::clone(&policy);
3046 rbac_middleware(p, None, req, next)
3047 }))
3048 }
3049
3050 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
3051 axum::Router::new()
3052 .route("/mcp", axum::routing::post(|| async { "ok" }))
3053 .layer(axum::middleware::from_fn(
3054 move |mut req: Request<Body>, next: Next| {
3055 let p = Arc::clone(&policy);
3056 let id = identity.clone();
3057 async move {
3058 req.extensions_mut().insert(id);
3059 rbac_middleware(p, None, req, next).await
3060 }
3061 },
3062 ))
3063 }
3064
3065 #[cfg(feature = "metrics")]
3069 #[tokio::test]
3070 async fn tool_limiter_deny_increments_counter() {
3071 use axum::extract::ConnectInfo;
3072
3073 let policy = Arc::new(test_policy());
3074 let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
3075 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
3076 let identity = AuthIdentity {
3077 method: crate::auth::AuthMethod::BearerToken,
3078 name: "alice".into(),
3079 role: "viewer".into(),
3080 raw_token: None,
3081 sub: None,
3082 };
3083 let app = {
3084 let metrics = Arc::clone(&metrics);
3085 axum::Router::new()
3086 .route("/mcp", axum::routing::post(|| async { "ok" }))
3087 .layer(axum::middleware::from_fn(
3088 move |mut req: Request<Body>, next: Next| {
3089 let p = Arc::clone(&policy);
3090 let l = Arc::clone(&limiter);
3091 let id = identity.clone();
3092 let m = Arc::clone(&metrics);
3093 async move {
3094 req.extensions_mut().insert(id);
3095 req.extensions_mut().insert(m);
3096 let peer: std::net::SocketAddr =
3097 "10.9.9.1:40000".parse().expect("static socket addr parses");
3098 req.extensions_mut().insert(ConnectInfo(peer));
3099 rbac_middleware(p, Some(l), req, next).await
3100 }
3101 },
3102 ))
3103 };
3104 let mk = || {
3105 Request::builder()
3106 .method(Method::POST)
3107 .uri("/mcp")
3108 .header("content-type", "application/json")
3109 .body(Body::from(tool_call_body(
3110 "resource_list",
3111 &serde_json::json!({}),
3112 )))
3113 .unwrap()
3114 };
3115 let counter = || {
3116 metrics
3117 .rate_limited_total
3118 .with_label_values(&["tool"])
3119 .get()
3120 };
3121
3122 let first = app.clone().oneshot(mk()).await.unwrap();
3123 assert_eq!(first.status(), StatusCode::OK);
3124 assert_eq!(counter(), 0, "successful call must not count");
3125
3126 let denied = app.clone().oneshot(mk()).await.unwrap();
3127 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
3128 assert_eq!(counter(), 1, "deny must increment the tool label");
3129 }
3130
3131 #[tokio::test]
3132 async fn middleware_passes_non_post() {
3133 let policy = Arc::new(test_policy());
3134 let app = rbac_router(policy);
3135 let req = Request::builder()
3137 .method(Method::GET)
3138 .uri("/mcp")
3139 .body(Body::empty())
3140 .unwrap();
3141 let resp = app.oneshot(req).await.unwrap();
3144 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
3145 }
3146
3147 #[tokio::test]
3148 async fn middleware_denies_without_identity() {
3149 let policy = Arc::new(test_policy());
3150 let app = rbac_router(policy);
3151 let body = tool_call_body("resource_list", &serde_json::json!({}));
3152 let req = Request::builder()
3153 .method(Method::POST)
3154 .uri("/mcp")
3155 .header("content-type", "application/json")
3156 .body(Body::from(body))
3157 .unwrap();
3158 let resp = app.oneshot(req).await.unwrap();
3159 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3160 }
3161
3162 fn global_deny_identity() -> AuthIdentity {
3163 AuthIdentity {
3164 method: crate::auth::AuthMethod::BearerToken,
3165 name: "alice".into(),
3166 role: "admin".into(),
3167 raw_token: None,
3168 sub: None,
3169 }
3170 }
3171
3172 fn global_deny_policy() -> Arc<RbacPolicy> {
3173 Arc::new(RbacPolicy::new(
3174 &RbacConfig::with_roles(vec![RoleConfig::new(
3175 "admin",
3176 vec!["*".into()],
3177 vec!["*".into()],
3178 )])
3179 .with_global_deny(vec!["*_delete_*".into()]),
3180 ))
3181 }
3182
3183 async fn global_deny_call(args: serde_json::Value, tool: &str) -> StatusCode {
3184 let app = rbac_router_with_identity(global_deny_policy(), global_deny_identity());
3185 let req = Request::builder()
3186 .method(Method::POST)
3187 .uri("/mcp")
3188 .header("content-type", "application/json")
3189 .body(Body::from(tool_call_body(tool, &args)))
3190 .unwrap();
3191 app.oneshot(req).await.unwrap().status()
3192 }
3193
3194 #[tokio::test]
3195 async fn middleware_global_deny_blocks_hostless_tool_call() {
3196 assert_eq!(
3197 global_deny_call(serde_json::json!({}), "jira_delete_issue").await,
3198 StatusCode::FORBIDDEN
3199 );
3200 assert_eq!(
3201 global_deny_call(serde_json::json!({}), "jira_get_issue").await,
3202 StatusCode::OK
3203 );
3204 }
3205
3206 #[tokio::test]
3207 async fn middleware_global_deny_blocks_host_scoped_tool_call() {
3208 assert_eq!(
3209 global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_delete_issue").await,
3210 StatusCode::FORBIDDEN
3211 );
3212 assert_eq!(
3213 global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_get_issue").await,
3214 StatusCode::OK
3215 );
3216 }
3217
3218 #[tokio::test]
3219 async fn middleware_allows_permitted_tool() {
3220 let policy = Arc::new(test_policy());
3221 let id = AuthIdentity {
3222 method: crate::auth::AuthMethod::BearerToken,
3223 name: "alice".into(),
3224 role: "viewer".into(),
3225 raw_token: None,
3226 sub: None,
3227 };
3228 let app = rbac_router_with_identity(policy, id);
3229 let body = tool_call_body("resource_list", &serde_json::json!({}));
3230 let req = Request::builder()
3231 .method(Method::POST)
3232 .uri("/mcp")
3233 .header("content-type", "application/json")
3234 .body(Body::from(body))
3235 .unwrap();
3236 let resp = app.oneshot(req).await.unwrap();
3237 assert_eq!(resp.status(), StatusCode::OK);
3238 }
3239
3240 #[tokio::test]
3241 async fn middleware_denies_unpermitted_tool() {
3242 let policy = Arc::new(test_policy());
3243 let id = AuthIdentity {
3244 method: crate::auth::AuthMethod::BearerToken,
3245 name: "alice".into(),
3246 role: "viewer".into(),
3247 raw_token: None,
3248 sub: None,
3249 };
3250 let app = rbac_router_with_identity(policy, id);
3251 let body = tool_call_body("resource_delete", &serde_json::json!({}));
3252 let req = Request::builder()
3253 .method(Method::POST)
3254 .uri("/mcp")
3255 .header("content-type", "application/json")
3256 .body(Body::from(body))
3257 .unwrap();
3258 let resp = app.oneshot(req).await.unwrap();
3259 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3260 }
3261
3262 #[tokio::test]
3263 async fn middleware_passes_non_tool_call_post() {
3264 let policy = Arc::new(test_policy());
3265 let id = AuthIdentity {
3266 method: crate::auth::AuthMethod::BearerToken,
3267 name: "alice".into(),
3268 role: "viewer".into(),
3269 raw_token: None,
3270 sub: None,
3271 };
3272 let app = rbac_router_with_identity(policy, id);
3273 let body = serde_json::json!({
3275 "jsonrpc": "2.0",
3276 "id": 1,
3277 "method": "resources/list"
3278 })
3279 .to_string();
3280 let req = Request::builder()
3281 .method(Method::POST)
3282 .uri("/mcp")
3283 .header("content-type", "application/json")
3284 .body(Body::from(body))
3285 .unwrap();
3286 let resp = app.oneshot(req).await.unwrap();
3287 assert_eq!(resp.status(), StatusCode::OK);
3288 }
3289
3290 #[tokio::test]
3291 async fn middleware_enforces_argument_allowlist() {
3292 let policy = Arc::new(test_policy());
3293 let id = AuthIdentity {
3294 method: crate::auth::AuthMethod::BearerToken,
3295 name: "dev".into(),
3296 role: "restricted-exec".into(),
3297 raw_token: None,
3298 sub: None,
3299 };
3300 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
3302 let body = tool_call_body(
3303 "resource_exec",
3304 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
3305 );
3306 let req = Request::builder()
3307 .method(Method::POST)
3308 .uri("/mcp")
3309 .body(Body::from(body))
3310 .unwrap();
3311 let resp = app.oneshot(req).await.unwrap();
3312 assert_eq!(resp.status(), StatusCode::OK);
3313
3314 let app = rbac_router_with_identity(policy, id);
3316 let body = tool_call_body(
3317 "resource_exec",
3318 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
3319 );
3320 let req = Request::builder()
3321 .method(Method::POST)
3322 .uri("/mcp")
3323 .body(Body::from(body))
3324 .unwrap();
3325 let resp = app.oneshot(req).await.unwrap();
3326 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3327 }
3328
3329 #[tokio::test]
3330 async fn middleware_disabled_policy_passes_everything() {
3331 let policy = Arc::new(RbacPolicy::disabled());
3332 let app = rbac_router(policy);
3333 let body = tool_call_body("anything", &serde_json::json!({}));
3335 let req = Request::builder()
3336 .method(Method::POST)
3337 .uri("/mcp")
3338 .body(Body::from(body))
3339 .unwrap();
3340 let resp = app.oneshot(req).await.unwrap();
3341 assert_eq!(resp.status(), StatusCode::OK);
3342 }
3343
3344 #[tokio::test]
3345 async fn middleware_batch_all_allowed_passes() {
3346 let policy = Arc::new(test_policy());
3347 let id = AuthIdentity {
3348 method: crate::auth::AuthMethod::BearerToken,
3349 name: "alice".into(),
3350 role: "viewer".into(),
3351 raw_token: None,
3352 sub: None,
3353 };
3354 let app = rbac_router_with_identity(policy, id);
3355 let body = serde_json::json!([
3356 {
3357 "jsonrpc": "2.0",
3358 "id": 1,
3359 "method": "tools/call",
3360 "params": { "name": "resource_list", "arguments": {} }
3361 },
3362 {
3363 "jsonrpc": "2.0",
3364 "id": 2,
3365 "method": "tools/call",
3366 "params": { "name": "system_info", "arguments": {} }
3367 }
3368 ])
3369 .to_string();
3370 let req = Request::builder()
3371 .method(Method::POST)
3372 .uri("/mcp")
3373 .header("content-type", "application/json")
3374 .body(Body::from(body))
3375 .unwrap();
3376 let resp = app.oneshot(req).await.unwrap();
3377 assert_eq!(resp.status(), StatusCode::OK);
3378 }
3379
3380 #[tokio::test]
3381 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
3382 let policy = Arc::new(test_policy());
3383 let id = AuthIdentity {
3384 method: crate::auth::AuthMethod::BearerToken,
3385 name: "alice".into(),
3386 role: "viewer".into(),
3387 raw_token: None,
3388 sub: None,
3389 };
3390 let app = rbac_router_with_identity(policy, id);
3391 let body = serde_json::json!([
3392 {
3393 "jsonrpc": "2.0",
3394 "id": 1,
3395 "method": "tools/call",
3396 "params": { "name": "resource_list", "arguments": {} }
3397 },
3398 {
3399 "jsonrpc": "2.0",
3400 "id": 2,
3401 "method": "tools/call",
3402 "params": { "name": "resource_delete", "arguments": {} }
3403 }
3404 ])
3405 .to_string();
3406 let req = Request::builder()
3407 .method(Method::POST)
3408 .uri("/mcp")
3409 .header("content-type", "application/json")
3410 .body(Body::from(body))
3411 .unwrap();
3412 let resp = app.oneshot(req).await.unwrap();
3413 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3414 }
3415
3416 #[tokio::test]
3417 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
3418 let policy = Arc::new(test_policy());
3419 let id = AuthIdentity {
3420 method: crate::auth::AuthMethod::BearerToken,
3421 name: "dev".into(),
3422 role: "restricted-exec".into(),
3423 raw_token: None,
3424 sub: None,
3425 };
3426 let app = rbac_router_with_identity(policy, id);
3427 let body = serde_json::json!([
3428 {
3429 "jsonrpc": "2.0",
3430 "id": 1,
3431 "method": "tools/call",
3432 "params": {
3433 "name": "resource_exec",
3434 "arguments": { "cmd": "ls -la", "host": "dev-1" }
3435 }
3436 },
3437 {
3438 "jsonrpc": "2.0",
3439 "id": 2,
3440 "method": "tools/call",
3441 "params": {
3442 "name": "resource_exec",
3443 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
3444 }
3445 }
3446 ])
3447 .to_string();
3448 let req = Request::builder()
3449 .method(Method::POST)
3450 .uri("/mcp")
3451 .header("content-type", "application/json")
3452 .body(Body::from(body))
3453 .unwrap();
3454 let resp = app.oneshot(req).await.unwrap();
3455 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3456 }
3457
3458 #[test]
3461 fn redact_with_salt_is_deterministic_per_salt() {
3462 let salt = b"unit-test-salt";
3463 let a = redact_with_salt(salt, "rm -rf /");
3464 let b = redact_with_salt(salt, "rm -rf /");
3465 assert_eq!(a, b, "same input + salt must yield identical hash");
3466 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
3467 assert!(
3468 a.chars().all(|c| c.is_ascii_hexdigit()),
3469 "redacted hash must be lowercase hex: {a}"
3470 );
3471 }
3472
3473 #[test]
3474 fn redact_with_salt_differs_across_salts() {
3475 let v = "the-same-value";
3476 let h1 = redact_with_salt(b"salt-one", v);
3477 let h2 = redact_with_salt(b"salt-two", v);
3478 assert_ne!(
3479 h1, h2,
3480 "different salts must produce different hashes for the same value"
3481 );
3482 }
3483
3484 #[test]
3485 fn redact_with_salt_distinguishes_values() {
3486 let salt = b"k";
3487 let h1 = redact_with_salt(salt, "alpha");
3488 let h2 = redact_with_salt(salt, "beta");
3489 assert_ne!(h1, h2, "different values must produce different hashes");
3491 }
3492
3493 #[test]
3494 fn policy_with_configured_salt_redacts_consistently() {
3495 let cfg = RbacConfig {
3496 enabled: true,
3497 roles: vec![],
3498 redaction_salt: Some(SecretString::from("my-stable-salt")),
3499 ..RbacConfig::default()
3500 };
3501 let p1 = RbacPolicy::new(&cfg);
3502 let p2 = RbacPolicy::new(&cfg);
3503 assert_eq!(
3504 p1.redact_arg("payload"),
3505 p2.redact_arg("payload"),
3506 "policies built from the same configured salt must agree"
3507 );
3508 }
3509
3510 #[test]
3511 fn policy_without_configured_salt_uses_process_salt() {
3512 let cfg = RbacConfig {
3513 enabled: true,
3514 roles: vec![],
3515 redaction_salt: None,
3516 ..RbacConfig::default()
3517 };
3518 let p1 = RbacPolicy::new(&cfg);
3519 let p2 = RbacPolicy::new(&cfg);
3520 assert_eq!(
3522 p1.redact_arg("payload"),
3523 p2.redact_arg("payload"),
3524 "process-wide salt must be consistent within one process"
3525 );
3526 }
3527
3528 #[tokio::test]
3540 async fn deny_path_uses_explicit_identity_not_task_local() {
3541 let policy = Arc::new(test_policy());
3542 let id = AuthIdentity {
3543 method: crate::auth::AuthMethod::BearerToken,
3544 name: "alice-the-auditor".into(),
3545 role: "viewer".into(),
3546 raw_token: None,
3547 sub: None,
3548 };
3549 let app = rbac_router_with_identity(policy, id);
3550 let body = tool_call_body("resource_delete", &serde_json::json!({}));
3552 let req = Request::builder()
3553 .method(Method::POST)
3554 .uri("/mcp")
3555 .header("content-type", "application/json")
3556 .body(Body::from(body))
3557 .unwrap();
3558 let resp = app.oneshot(req).await.unwrap();
3559 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3560 }
3561
3562 fn restricted_exec_identity() -> AuthIdentity {
3565 AuthIdentity {
3566 method: crate::auth::AuthMethod::BearerToken,
3567 name: "carol".into(),
3568 role: "restricted-exec".into(),
3569 raw_token: None,
3570 sub: None,
3571 }
3572 }
3573
3574 #[test]
3575 fn has_argument_allowlist_matches_configured_tool_argument() {
3576 let policy = test_policy();
3577 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
3578 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
3579 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
3580 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
3581 }
3582
3583 #[tokio::test]
3584 async fn array_arg_with_matching_allowlist_is_denied() {
3585 let policy = Arc::new(test_policy());
3586 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3587 let body = tool_call_body(
3588 "resource_exec",
3589 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
3590 );
3591 let req = Request::builder()
3592 .method(Method::POST)
3593 .uri("/mcp")
3594 .header("content-type", "application/json")
3595 .body(Body::from(body))
3596 .unwrap();
3597 let resp = app.oneshot(req).await.unwrap();
3598 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3599 }
3600
3601 #[tokio::test]
3602 async fn object_arg_with_matching_allowlist_is_denied() {
3603 let policy = Arc::new(test_policy());
3604 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3605 let body = tool_call_body(
3606 "resource_exec",
3607 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
3608 );
3609 let req = Request::builder()
3610 .method(Method::POST)
3611 .uri("/mcp")
3612 .header("content-type", "application/json")
3613 .body(Body::from(body))
3614 .unwrap();
3615 let resp = app.oneshot(req).await.unwrap();
3616 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3617 }
3618
3619 #[tokio::test]
3620 async fn number_arg_with_matching_allowlist_is_denied() {
3621 let policy = Arc::new(test_policy());
3622 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3623 let body = tool_call_body(
3624 "resource_exec",
3625 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
3626 );
3627 let req = Request::builder()
3628 .method(Method::POST)
3629 .uri("/mcp")
3630 .header("content-type", "application/json")
3631 .body(Body::from(body))
3632 .unwrap();
3633 let resp = app.oneshot(req).await.unwrap();
3634 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3635 }
3636
3637 #[tokio::test]
3638 async fn bool_arg_with_matching_allowlist_is_denied() {
3639 let policy = Arc::new(test_policy());
3640 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3641 let body = tool_call_body(
3642 "resource_exec",
3643 &serde_json::json!({ "host": "dev-1", "cmd": true }),
3644 );
3645 let req = Request::builder()
3646 .method(Method::POST)
3647 .uri("/mcp")
3648 .header("content-type", "application/json")
3649 .body(Body::from(body))
3650 .unwrap();
3651 let resp = app.oneshot(req).await.unwrap();
3652 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3653 }
3654
3655 #[tokio::test]
3656 async fn null_arg_with_matching_allowlist_is_denied() {
3657 let policy = Arc::new(test_policy());
3658 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3659 let body = tool_call_body(
3660 "resource_exec",
3661 &serde_json::json!({ "host": "dev-1", "cmd": null }),
3662 );
3663 let req = Request::builder()
3664 .method(Method::POST)
3665 .uri("/mcp")
3666 .header("content-type", "application/json")
3667 .body(Body::from(body))
3668 .unwrap();
3669 let resp = app.oneshot(req).await.unwrap();
3670 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3671 }
3672
3673 #[tokio::test]
3674 async fn non_string_arg_without_allowlist_is_passthrough() {
3675 let policy = Arc::new(test_policy());
3679 let id = AuthIdentity {
3680 method: crate::auth::AuthMethod::BearerToken,
3681 name: "olivia".into(),
3682 role: "ops".into(),
3683 raw_token: None,
3684 sub: None,
3685 };
3686 let app = rbac_router_with_identity(policy, id);
3687 let body = tool_call_body(
3688 "resource_exec",
3689 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
3690 );
3691 let req = Request::builder()
3692 .method(Method::POST)
3693 .uri("/mcp")
3694 .header("content-type", "application/json")
3695 .body(Body::from(body))
3696 .unwrap();
3697 let resp = app.oneshot(req).await.unwrap();
3698 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3699 }
3700
3701 #[tokio::test]
3702 async fn string_arg_in_allowlist_still_passes() {
3703 let policy = Arc::new(test_policy());
3704 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3705 let body = tool_call_body(
3706 "resource_exec",
3707 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
3708 );
3709 let req = Request::builder()
3710 .method(Method::POST)
3711 .uri("/mcp")
3712 .header("content-type", "application/json")
3713 .body(Body::from(body))
3714 .unwrap();
3715 let resp = app.oneshot(req).await.unwrap();
3716 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3717 }
3718
3719 async fn exec_status(args: &serde_json::Value) -> StatusCode {
3728 let policy = Arc::new(test_policy());
3729 let app = rbac_router_with_identity(policy, restricted_exec_identity());
3730 let body = tool_call_body("resource_exec", args);
3731 let req = Request::builder()
3732 .method(Method::POST)
3733 .uri("/mcp")
3734 .header("content-type", "application/json")
3735 .body(Body::from(body))
3736 .unwrap();
3737 app.oneshot(req).await.unwrap().status()
3738 }
3739
3740 #[tokio::test]
3741 async fn non_string_host_is_denied_for_every_json_type() {
3742 for host in [
3743 serde_json::json!(["prod-1"]),
3744 serde_json::json!({ "name": "prod-1" }),
3745 serde_json::json!(42),
3746 serde_json::json!(true),
3747 serde_json::json!(null),
3748 ] {
3749 let args = serde_json::json!({ "host": host, "cmd": "sh" });
3750 assert_eq!(
3751 exec_status(&args).await,
3752 StatusCode::FORBIDDEN,
3753 "non-string host must not bypass host globs: {host:?}"
3754 );
3755 }
3756 }
3757
3758 #[tokio::test]
3759 async fn string_host_outside_globs_still_denied() {
3760 let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
3761 assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
3762 }
3763
3764 #[tokio::test]
3765 async fn string_host_inside_globs_still_allowed() {
3766 let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
3767 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3768 }
3769
3770 #[tokio::test]
3774 async fn absent_host_still_routes_to_check_operation() {
3775 let args = serde_json::json!({ "cmd": "sh" });
3776 assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3777 }
3778
3779 fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
3788 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
3789 .with_argument_allowlists(vec![
3790 ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
3791 ]);
3792 let mut config = RbacConfig::with_roles(vec![role]);
3793 config.enabled = true;
3794 RbacPolicy::new(&config)
3795 }
3796
3797 fn viewer_identity() -> AuthIdentity {
3798 AuthIdentity {
3799 method: crate::auth::AuthMethod::BearerToken,
3800 name: "viewer-1".into(),
3801 role: "viewer".into(),
3802 raw_token: None,
3803 sub: None,
3804 }
3805 }
3806
3807 async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
3808 let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
3809 let body = serde_json::json!({
3810 "jsonrpc": "2.0",
3811 "id": 1,
3812 "method": "tools/call",
3813 "params": params
3814 })
3815 .to_string();
3816 let req = Request::builder()
3817 .method(Method::POST)
3818 .uri("/mcp")
3819 .header("content-type", "application/json")
3820 .body(Body::from(body))
3821 .unwrap();
3822 app.oneshot(req).await.unwrap().status()
3823 }
3824
3825 #[tokio::test]
3826 async fn required_false_still_allows_omitting_the_argument() {
3827 let params = serde_json::json!({ "name": "run", "arguments": {} });
3828 assert_ne!(
3829 run_status(required_policy(vec!["ls".into()], false), ¶ms).await,
3830 StatusCode::FORBIDDEN,
3831 "default behaviour must be unchanged"
3832 );
3833 }
3834
3835 #[tokio::test]
3836 async fn required_true_denies_omitted_argument() {
3837 let params = serde_json::json!({ "name": "run", "arguments": {} });
3838 assert_eq!(
3839 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3840 StatusCode::FORBIDDEN
3841 );
3842 }
3843
3844 #[tokio::test]
3845 async fn required_true_allows_permitted_value() {
3846 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
3847 assert_ne!(
3848 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3849 StatusCode::FORBIDDEN
3850 );
3851 }
3852
3853 #[tokio::test]
3854 async fn required_true_still_denies_disallowed_value() {
3855 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
3856 assert_eq!(
3857 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3858 StatusCode::FORBIDDEN
3859 );
3860 }
3861
3862 #[tokio::test]
3863 async fn required_true_denies_non_string_value() {
3864 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
3865 assert_eq!(
3866 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3867 StatusCode::FORBIDDEN
3868 );
3869 }
3870
3871 #[tokio::test]
3872 async fn required_true_denies_absent_or_non_object_arguments() {
3873 for params in [
3874 serde_json::json!({ "name": "run" }),
3875 serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
3876 serde_json::json!({ "name": "run", "arguments": null }),
3877 ] {
3878 assert_eq!(
3879 run_status(required_policy(vec!["ls".into()], true), ¶ms).await,
3880 StatusCode::FORBIDDEN,
3881 "omitting the arguments object must not skip `required`: {params:?}"
3882 );
3883 }
3884 }
3885
3886 #[tokio::test]
3889 async fn required_true_with_empty_allowed_accepts_any_string() {
3890 let params =
3891 serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
3892 assert_ne!(
3893 run_status(required_policy(vec![], true), ¶ms).await,
3894 StatusCode::FORBIDDEN
3895 );
3896 }
3897
3898 #[tokio::test]
3899 async fn required_true_with_empty_allowed_denies_omitted_argument() {
3900 let params = serde_json::json!({ "name": "run", "arguments": {} });
3901 assert_eq!(
3902 run_status(required_policy(vec![], true), ¶ms).await,
3903 StatusCode::FORBIDDEN
3904 );
3905 }
3906
3907 #[tokio::test]
3908 async fn required_true_with_empty_allowed_denies_non_string() {
3909 let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
3910 assert_eq!(
3911 run_status(required_policy(vec![], true), ¶ms).await,
3912 StatusCode::FORBIDDEN
3913 );
3914 }
3915
3916 #[tokio::test]
3917 async fn required_honours_globbed_tool_patterns() {
3918 let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
3919 .with_argument_allowlists(vec![
3920 ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
3921 ]);
3922 let mut config = RbacConfig::with_roles(vec![role]);
3923 config.enabled = true;
3924 let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
3925 assert_eq!(
3926 run_status(RbacPolicy::new(&config), ¶ms).await,
3927 StatusCode::FORBIDDEN,
3928 "a globbed tool pattern must enforce presence, not just value"
3929 );
3930 }
3931
3932 #[test]
3933 fn required_defaults_to_false_when_absent_from_toml() {
3934 let cfg: RbacConfig = toml::from_str(
3935 r#"
3936 enabled = true
3937 [[roles]]
3938 name = "viewer"
3939 allow = ["run"]
3940 [[roles.argument_allowlists]]
3941 tool = "run"
3942 argument = "cmd"
3943 allowed = ["ls"]
3944 "#,
3945 )
3946 .expect("config without `required` must still deserialize");
3947 assert!(
3948 !cfg.roles[0].argument_allowlists[0].required,
3949 "omitted `required` must default to false so existing configs are unchanged"
3950 );
3951 }
3952
3953 #[test]
3954 fn unknown_rbac_config_key_is_rejected() {
3955 let err = toml::from_str::<RbacConfig>(
3956 "
3957 enabled = true
3958 typo_roles = []
3959 ",
3960 )
3961 .unwrap_err();
3962
3963 let msg = err.to_string();
3964 assert!(
3965 msg.contains("typo_roles"),
3966 "error must name the offending key: {msg}"
3967 );
3968 }
3969}