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