1use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
12
13use axum::{
14 body::Body,
15 extract::ConnectInfo,
16 http::{Method, Request, StatusCode},
17 middleware::Next,
18 response::{IntoResponse, Response},
19};
20use hmac::{Hmac, KeyInit, Mac};
21use http_body_util::BodyExt;
22use secrecy::{ExposeSecret, SecretString};
23use serde::Deserialize;
24use sha2::Sha256;
25
26use crate::{
27 auth::{AuthIdentity, TlsConnInfo},
28 bounded_limiter::BoundedKeyedLimiter,
29 error::McpxError,
30};
31
32pub(crate) type ToolRateLimiter = BoundedKeyedLimiter<IpAddr>;
35
36const DEFAULT_TOOL_RATE: NonZeroU32 = NonZeroU32::new(120).unwrap();
39
40const DEFAULT_TOOL_MAX_TRACKED_KEYS: usize = 10_000;
43
44const DEFAULT_TOOL_IDLE_EVICTION: Duration = Duration::from_mins(15);
46
47#[must_use]
53pub(crate) fn build_tool_rate_limiter(max_per_minute: u32) -> Arc<ToolRateLimiter> {
54 build_tool_rate_limiter_with_bounds(
55 max_per_minute,
56 DEFAULT_TOOL_MAX_TRACKED_KEYS,
57 DEFAULT_TOOL_IDLE_EVICTION,
58 )
59}
60
61#[must_use]
63pub(crate) fn build_tool_rate_limiter_with_bounds(
64 max_per_minute: u32,
65 max_tracked_keys: usize,
66 idle_eviction: Duration,
67) -> Arc<ToolRateLimiter> {
68 let quota =
69 governor::Quota::per_minute(NonZeroU32::new(max_per_minute).unwrap_or(DEFAULT_TOOL_RATE));
70 Arc::new(BoundedKeyedLimiter::new(
71 quota,
72 max_tracked_keys,
73 idle_eviction,
74 ))
75}
76
77tokio::task_local! {
84 static CURRENT_ROLE: String;
85 static CURRENT_IDENTITY: String;
86 static CURRENT_TOKEN: SecretString;
87 static CURRENT_SUB: String;
88}
89
90#[must_use]
93pub fn current_role() -> Option<String> {
94 CURRENT_ROLE.try_with(Clone::clone).ok()
95}
96
97#[must_use]
100pub fn current_identity() -> Option<String> {
101 CURRENT_IDENTITY.try_with(Clone::clone).ok()
102}
103
104#[must_use]
117pub fn current_token() -> Option<SecretString> {
118 CURRENT_TOKEN
119 .try_with(|t| {
120 if t.expose_secret().is_empty() {
121 None
122 } else {
123 Some(t.clone())
124 }
125 })
126 .ok()
127 .flatten()
128}
129
130#[must_use]
134pub fn current_sub() -> Option<String> {
135 CURRENT_SUB
136 .try_with(Clone::clone)
137 .ok()
138 .filter(|s| !s.is_empty())
139}
140
141pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
146 CURRENT_TOKEN.scope(token, f).await
147}
148
149pub async fn with_rbac_scope<F: Future>(
154 role: String,
155 identity: String,
156 token: SecretString,
157 sub: String,
158 f: F,
159) -> F::Output {
160 CURRENT_ROLE
161 .scope(
162 role,
163 CURRENT_IDENTITY.scope(
164 identity,
165 CURRENT_TOKEN.scope(token, CURRENT_SUB.scope(sub, f)),
166 ),
167 )
168 .await
169}
170
171#[derive(Debug, Clone, Deserialize)]
173#[non_exhaustive]
174pub struct RoleConfig {
175 pub name: String,
177 #[serde(default)]
179 pub description: Option<String>,
180 #[serde(default)]
182 pub allow: Vec<String>,
183 #[serde(default)]
185 pub deny: Vec<String>,
186 #[serde(default = "default_hosts")]
188 pub hosts: Vec<String>,
189 #[serde(default)]
193 pub argument_allowlists: Vec<ArgumentAllowlist>,
194}
195
196impl RoleConfig {
197 #[must_use]
199 pub fn new(name: impl Into<String>, allow: Vec<String>, hosts: Vec<String>) -> Self {
200 Self {
201 name: name.into(),
202 description: None,
203 allow,
204 deny: vec![],
205 hosts,
206 argument_allowlists: vec![],
207 }
208 }
209
210 #[must_use]
212 pub fn with_argument_allowlists(mut self, allowlists: Vec<ArgumentAllowlist>) -> Self {
213 self.argument_allowlists = allowlists;
214 self
215 }
216}
217
218#[derive(Debug, Clone, Deserialize)]
241#[non_exhaustive]
242pub struct ArgumentAllowlist {
243 pub tool: String,
245 pub argument: String,
247 #[serde(default)]
249 pub allowed: Vec<String>,
250}
251
252impl ArgumentAllowlist {
253 #[must_use]
255 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
256 Self {
257 tool: tool.into(),
258 argument: argument.into(),
259 allowed,
260 }
261 }
262}
263
264fn default_hosts() -> Vec<String> {
265 vec!["*".into()]
266}
267
268#[derive(Debug, Clone, Default, Deserialize)]
270#[non_exhaustive]
271pub struct RbacConfig {
272 #[serde(default)]
274 pub enabled: bool,
275 #[serde(default)]
277 pub roles: Vec<RoleConfig>,
278 #[serde(default)]
287 pub redaction_salt: Option<SecretString>,
288}
289
290impl RbacConfig {
291 #[must_use]
293 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
294 Self {
295 enabled: true,
296 roles,
297 redaction_salt: None,
298 }
299 }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304#[non_exhaustive]
305pub enum RbacDecision {
306 Allow,
308 Deny,
310}
311
312#[derive(Debug, Clone, serde::Serialize)]
314#[non_exhaustive]
315pub struct RbacRoleSummary {
316 pub name: String,
318 pub allow: usize,
320 pub deny: usize,
322 pub hosts: usize,
324 pub argument_allowlists: usize,
326}
327
328#[derive(Debug, Clone, serde::Serialize)]
330#[non_exhaustive]
331pub struct RbacPolicySummary {
332 pub enabled: bool,
334 pub roles: Vec<RbacRoleSummary>,
336}
337
338#[derive(Debug, Clone)]
344#[non_exhaustive]
345pub struct RbacPolicy {
346 roles: Vec<RoleConfig>,
347 enabled: bool,
348 redaction_salt: Arc<SecretString>,
351}
352
353impl RbacPolicy {
354 #[must_use]
357 pub fn new(config: &RbacConfig) -> Self {
358 let salt = config
359 .redaction_salt
360 .clone()
361 .unwrap_or_else(|| process_redaction_salt().clone());
362 Self {
363 roles: config.roles.clone(),
364 enabled: config.enabled,
365 redaction_salt: Arc::new(salt),
366 }
367 }
368
369 #[must_use]
371 pub fn disabled() -> Self {
372 Self {
373 roles: Vec::new(),
374 enabled: false,
375 redaction_salt: Arc::new(process_redaction_salt().clone()),
376 }
377 }
378
379 #[must_use]
381 pub fn is_enabled(&self) -> bool {
382 self.enabled
383 }
384
385 #[must_use]
390 pub fn summary(&self) -> RbacPolicySummary {
391 let roles = self
392 .roles
393 .iter()
394 .map(|r| RbacRoleSummary {
395 name: r.name.clone(),
396 allow: r.allow.len(),
397 deny: r.deny.len(),
398 hosts: r.hosts.len(),
399 argument_allowlists: r.argument_allowlists.len(),
400 })
401 .collect();
402 RbacPolicySummary {
403 enabled: self.enabled,
404 roles,
405 }
406 }
407
408 #[must_use]
413 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
414 if !self.enabled {
415 return RbacDecision::Allow;
416 }
417 let Some(role_cfg) = self.find_role(role) else {
418 return RbacDecision::Deny;
419 };
420 if role_cfg.deny.iter().any(|d| d == operation) {
421 return RbacDecision::Deny;
422 }
423 if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
424 return RbacDecision::Allow;
425 }
426 RbacDecision::Deny
427 }
428
429 #[must_use]
436 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
437 if !self.enabled {
438 return RbacDecision::Allow;
439 }
440 let Some(role_cfg) = self.find_role(role) else {
441 return RbacDecision::Deny;
442 };
443 if role_cfg.deny.iter().any(|d| d == operation) {
444 return RbacDecision::Deny;
445 }
446 if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
447 return RbacDecision::Deny;
448 }
449 if !Self::host_matches(&role_cfg.hosts, host) {
450 return RbacDecision::Deny;
451 }
452 RbacDecision::Allow
453 }
454
455 #[must_use]
457 pub fn host_visible(&self, role: &str, host: &str) -> bool {
458 if !self.enabled {
459 return true;
460 }
461 let Some(role_cfg) = self.find_role(role) else {
462 return false;
463 };
464 Self::host_matches(&role_cfg.hosts, host)
465 }
466
467 #[must_use]
469 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
470 self.find_role(role).map(|r| r.hosts.as_slice())
471 }
472
473 #[must_use]
502 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
503 if !self.enabled {
504 return true;
505 }
506 let Some(role_cfg) = self.find_role(role) else {
507 return false;
508 };
509 for al in &role_cfg.argument_allowlists {
510 if al.tool != tool && !glob_match(&al.tool, tool) {
511 continue;
512 }
513 if al.argument != argument {
514 continue;
515 }
516 if al.allowed.is_empty() {
517 continue;
518 }
519 let Some(tokens) = shlex::split(value) else {
524 return false;
525 };
526 let Some(first_token) = tokens.first() else {
527 return false;
528 };
529 if first_token.is_empty() {
533 return false;
534 }
535 let basename = first_token
539 .rsplit('/')
540 .next()
541 .unwrap_or(first_token.as_str());
542 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
543 return false;
544 }
545 }
546 true
547 }
548
549 #[must_use]
559 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
560 if !self.enabled {
561 return false;
562 }
563 let Some(role_cfg) = self.find_role(role) else {
564 return false;
565 };
566 role_cfg.argument_allowlists.iter().any(|al| {
567 (al.tool == tool || glob_match(&al.tool, tool))
568 && al.argument == argument
569 && !al.allowed.is_empty()
570 })
571 }
572
573 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
575 self.roles.iter().find(|r| r.name == name)
576 }
577
578 fn host_matches(patterns: &[String], host: &str) -> bool {
580 patterns.iter().any(|p| glob_match(p, host))
581 }
582
583 #[must_use]
592 pub fn redact_arg(&self, value: &str) -> String {
593 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
594 }
595}
596
597fn process_redaction_salt() -> &'static SecretString {
600 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
601 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
602 PROCESS_SALT.get_or_init(|| {
603 let mut bytes = [0u8; 32];
604 rand::fill(&mut bytes);
605 SecretString::from(STANDARD_NO_PAD.encode(bytes))
608 })
609}
610
611fn redact_with_salt(salt: &[u8], value: &str) -> String {
616 use std::fmt::Write as _;
617
618 use sha2::Digest as _;
619
620 type HmacSha256 = Hmac<Sha256>;
621 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
627 m
628 } else {
629 let digest = Sha256::digest(salt);
630 #[allow(
631 clippy::expect_used,
632 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
633 )]
634 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
635 };
636 mac.update(value.as_bytes());
637 let bytes = mac.finalize().into_bytes();
638 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
640 let mut out = String::with_capacity(8);
641 for b in prefix {
642 let _ = write!(out, "{b:02x}");
643 }
644 out
645}
646
647#[allow(
668 clippy::too_many_lines,
669 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
670)]
671pub(crate) async fn rbac_middleware(
672 policy: Arc<RbacPolicy>,
673 tool_limiter: Option<Arc<ToolRateLimiter>>,
674 req: Request<Body>,
675 next: Next,
676) -> Response {
677 if req.method() != Method::POST {
679 return next.run(req).await;
680 }
681
682 let peer_ip: Option<IpAddr> = req
684 .extensions()
685 .get::<ConnectInfo<std::net::SocketAddr>>()
686 .map(|ci| ci.0.ip())
687 .or_else(|| {
688 req.extensions()
689 .get::<ConnectInfo<TlsConnInfo>>()
690 .map(|ci| ci.0.addr.ip())
691 });
692
693 let identity = req.extensions().get::<AuthIdentity>();
695 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
696 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
697 let raw_token: SecretString = identity
700 .and_then(|id| id.raw_token.clone())
701 .unwrap_or_else(|| SecretString::from(String::new()));
702 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
703
704 if policy.is_enabled() && identity.is_none() {
706 return McpxError::Rbac("no authenticated identity".into()).into_response();
707 }
708
709 let (parts, body) = req.into_parts();
711 let bytes = match body.collect().await {
712 Ok(collected) => collected.to_bytes(),
713 Err(e) => {
714 tracing::error!(error = %e, "failed to read request body");
715 return (
716 StatusCode::INTERNAL_SERVER_ERROR,
717 "failed to read request body",
718 )
719 .into_response();
720 }
721 };
722
723 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
725 let tool_calls = extract_tool_calls(&json);
726 if !tool_calls.is_empty() {
727 for params in tool_calls {
728 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
729 return resp;
730 }
731 if policy.is_enabled()
732 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
733 {
734 return resp;
735 }
736 }
737 }
738 }
739 let req = Request::from_parts(parts, Body::from(bytes));
743
744 if role.is_empty() {
746 next.run(req).await
747 } else {
748 CURRENT_ROLE
749 .scope(
750 role,
751 CURRENT_IDENTITY.scope(
752 identity_name,
753 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
754 ),
755 )
756 .await
757 }
758}
759
760fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
766 match value {
767 serde_json::Value::Object(map) => map
768 .get("method")
769 .and_then(serde_json::Value::as_str)
770 .filter(|method| *method == "tools/call")
771 .and_then(|_| map.get("params"))
772 .into_iter()
773 .collect(),
774 serde_json::Value::Array(items) => items
775 .iter()
776 .filter_map(|item| match item {
777 serde_json::Value::Object(map) => map
778 .get("method")
779 .and_then(serde_json::Value::as_str)
780 .filter(|method| *method == "tools/call")
781 .and_then(|_| map.get("params")),
782 serde_json::Value::Null
783 | serde_json::Value::Bool(_)
784 | serde_json::Value::Number(_)
785 | serde_json::Value::String(_)
786 | serde_json::Value::Array(_) => None,
787 })
788 .collect(),
789 serde_json::Value::Null
790 | serde_json::Value::Bool(_)
791 | serde_json::Value::Number(_)
792 | serde_json::Value::String(_) => Vec::new(),
793 }
794}
795
796fn enforce_rate_limit(
799 tool_limiter: Option<&ToolRateLimiter>,
800 peer_ip: Option<IpAddr>,
801) -> Option<Response> {
802 let limiter = tool_limiter?;
803 let ip = peer_ip?;
804 if limiter.check_key(&ip).is_err() {
805 tracing::warn!(%ip, "tool invocation rate limited");
806 return Some(McpxError::RateLimited("too many tool invocations".into()).into_response());
807 }
808 None
809}
810
811fn enforce_tool_policy(
820 policy: &RbacPolicy,
821 identity_name: &str,
822 role: &str,
823 params: &serde_json::Value,
824) -> Option<Response> {
825 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
826 let host = params
827 .get("arguments")
828 .and_then(|a| a.get("host"))
829 .and_then(|h| h.as_str());
830
831 let decision = if let Some(host) = host {
832 policy.check(role, tool_name, host)
833 } else {
834 policy.check_operation(role, tool_name)
835 };
836 if decision == RbacDecision::Deny {
837 tracing::warn!(
838 user = %identity_name,
839 role = %role,
840 tool = tool_name,
841 host = host.unwrap_or("-"),
842 "RBAC denied"
843 );
844 return Some(
845 McpxError::Rbac(format!("{tool_name} denied for role '{role}'")).into_response(),
846 );
847 }
848
849 let args = params.get("arguments").and_then(|a| a.as_object())?;
850 for (arg_key, arg_val) in args {
851 if let Some(resp) = check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
852 {
853 return Some(resp);
854 }
855 }
856 None
857}
858
859fn check_argument(
860 policy: &RbacPolicy,
861 identity_name: &str,
862 role: &str,
863 tool_name: &str,
864 arg_key: &str,
865 arg_val: &serde_json::Value,
866) -> Option<Response> {
867 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
868 return None;
869 }
870 let Some(val_str) = arg_val.as_str() else {
871 tracing::warn!(
877 user = %identity_name,
878 role = %role,
879 tool = tool_name,
880 argument = arg_key,
881 value_type = json_value_type(arg_val),
882 "non-string argument rejected by allowlist"
883 );
884 return Some(
885 McpxError::Rbac(format!(
886 "argument '{arg_key}' must be a string for tool '{tool_name}'"
887 ))
888 .into_response(),
889 );
890 };
891 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
892 return None;
893 }
894 tracing::warn!(
899 user = %identity_name,
900 role = %role,
901 tool = tool_name,
902 argument = arg_key,
903 arg_hmac = %policy.redact_arg(val_str),
904 "argument not in allowlist"
905 );
906 Some(
907 McpxError::Rbac(format!(
908 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
909 ))
910 .into_response(),
911 )
912}
913
914fn json_value_type(v: &serde_json::Value) -> &'static str {
915 match v {
916 serde_json::Value::Null => "null",
917 serde_json::Value::Bool(_) => "bool",
918 serde_json::Value::Number(_) => "number",
919 serde_json::Value::String(_) => "string",
920 serde_json::Value::Array(_) => "array",
921 serde_json::Value::Object(_) => "object",
922 }
923}
924
925fn glob_match(pattern: &str, text: &str) -> bool {
930 let parts: Vec<&str> = pattern.split('*').collect();
931 if parts.len() == 1 {
932 return pattern == text;
934 }
935
936 let mut pos = 0;
937
938 if let Some(&first) = parts.first()
940 && !first.is_empty()
941 {
942 if !text.starts_with(first) {
943 return false;
944 }
945 pos = first.len();
946 }
947
948 if let Some(&last) = parts.last()
950 && !last.is_empty()
951 {
952 if !text[pos..].ends_with(last) {
953 return false;
954 }
955 let end = text.len() - last.len();
957 if pos > end {
958 return false;
959 }
960 let middle = &text[pos..end];
962 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
963 return match_middle(middle, middle_parts);
964 }
965
966 let middle = &text[pos..];
968 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
969 match_middle(middle, middle_parts)
970}
971
972fn match_middle(mut text: &str, parts: &[&str]) -> bool {
974 for part in parts {
975 if part.is_empty() {
976 continue;
977 }
978 if let Some(idx) = text.find(part) {
979 text = &text[idx + part.len()..];
980 } else {
981 return false;
982 }
983 }
984 true
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990
991 fn test_policy() -> RbacPolicy {
992 RbacPolicy::new(&RbacConfig {
993 enabled: true,
994 roles: vec![
995 RoleConfig {
996 name: "viewer".into(),
997 description: Some("Read-only".into()),
998 allow: vec![
999 "list_hosts".into(),
1000 "resource_list".into(),
1001 "resource_inspect".into(),
1002 "resource_logs".into(),
1003 "system_info".into(),
1004 ],
1005 deny: vec![],
1006 hosts: vec!["*".into()],
1007 argument_allowlists: vec![],
1008 },
1009 RoleConfig {
1010 name: "deploy".into(),
1011 description: Some("Lifecycle management".into()),
1012 allow: vec![
1013 "list_hosts".into(),
1014 "resource_list".into(),
1015 "resource_run".into(),
1016 "resource_start".into(),
1017 "resource_stop".into(),
1018 "resource_restart".into(),
1019 "resource_logs".into(),
1020 "image_pull".into(),
1021 ],
1022 deny: vec!["resource_delete".into(), "resource_exec".into()],
1023 hosts: vec!["web-*".into(), "api-*".into()],
1024 argument_allowlists: vec![],
1025 },
1026 RoleConfig {
1027 name: "ops".into(),
1028 description: Some("Full access".into()),
1029 allow: vec!["*".into()],
1030 deny: vec![],
1031 hosts: vec!["*".into()],
1032 argument_allowlists: vec![],
1033 },
1034 RoleConfig {
1035 name: "restricted-exec".into(),
1036 description: Some("Exec with argument allowlist".into()),
1037 allow: vec!["resource_exec".into()],
1038 deny: vec![],
1039 hosts: vec!["dev-*".into()],
1040 argument_allowlists: vec![ArgumentAllowlist {
1041 tool: "resource_exec".into(),
1042 argument: "cmd".into(),
1043 allowed: vec![
1044 "sh".into(),
1045 "bash".into(),
1046 "cat".into(),
1047 "ls".into(),
1048 "ps".into(),
1049 ],
1050 }],
1051 },
1052 ],
1053 redaction_salt: None,
1054 })
1055 }
1056
1057 #[test]
1060 fn glob_exact_match() {
1061 assert!(glob_match("web-prod-1", "web-prod-1"));
1062 assert!(!glob_match("web-prod-1", "web-prod-2"));
1063 }
1064
1065 #[test]
1066 fn glob_star_suffix() {
1067 assert!(glob_match("web-*", "web-prod-1"));
1068 assert!(glob_match("web-*", "web-staging"));
1069 assert!(!glob_match("web-*", "api-prod"));
1070 }
1071
1072 #[test]
1073 fn glob_star_prefix() {
1074 assert!(glob_match("*-prod", "web-prod"));
1075 assert!(glob_match("*-prod", "api-prod"));
1076 assert!(!glob_match("*-prod", "web-staging"));
1077 }
1078
1079 #[test]
1080 fn glob_star_middle() {
1081 assert!(glob_match("web-*-prod", "web-us-prod"));
1082 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1083 assert!(!glob_match("web-*-prod", "web-staging"));
1084 }
1085
1086 #[test]
1087 fn glob_star_only() {
1088 assert!(glob_match("*", "anything"));
1089 assert!(glob_match("*", ""));
1090 }
1091
1092 #[test]
1093 fn glob_multiple_stars() {
1094 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1095 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1096 }
1097
1098 #[test]
1110 fn glob_prefix_and_suffix_meet_exactly() {
1111 assert!(glob_match("ab*cd", "abcd"));
1114 }
1115
1116 #[test]
1121 fn glob_middle_segment_required_with_suffix() {
1122 assert!(!glob_match("a*b*c", "axyc"));
1127 }
1128
1129 #[test]
1135 fn glob_match_middle_advances_past_matched_part() {
1136 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1141 }
1142
1143 #[test]
1148 fn glob_match_middle_uses_addition_not_multiplication() {
1149 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1153 }
1154
1155 #[test]
1164 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1165 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1173 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1174 "run-*",
1175 "cmd",
1176 vec!["ls".into()],
1177 )]);
1178 let mut config = RbacConfig::with_roles(vec![role]);
1179 config.enabled = true;
1180 let policy = RbacPolicy::new(&config);
1181 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1182 }
1183
1184 #[test]
1187 fn disabled_policy_allows_everything() {
1188 let policy = RbacPolicy::new(&RbacConfig {
1189 enabled: false,
1190 roles: vec![],
1191 redaction_salt: None,
1192 });
1193 assert_eq!(
1194 policy.check("nonexistent", "resource_delete", "any-host"),
1195 RbacDecision::Allow
1196 );
1197 }
1198
1199 #[test]
1200 fn unknown_role_denied() {
1201 let policy = test_policy();
1202 assert_eq!(
1203 policy.check("unknown", "resource_list", "web-prod-1"),
1204 RbacDecision::Deny
1205 );
1206 }
1207
1208 #[test]
1209 fn viewer_allowed_read_ops() {
1210 let policy = test_policy();
1211 assert_eq!(
1212 policy.check("viewer", "resource_list", "web-prod-1"),
1213 RbacDecision::Allow
1214 );
1215 assert_eq!(
1216 policy.check("viewer", "system_info", "db-host"),
1217 RbacDecision::Allow
1218 );
1219 }
1220
1221 #[test]
1222 fn viewer_denied_write_ops() {
1223 let policy = test_policy();
1224 assert_eq!(
1225 policy.check("viewer", "resource_run", "web-prod-1"),
1226 RbacDecision::Deny
1227 );
1228 assert_eq!(
1229 policy.check("viewer", "resource_delete", "web-prod-1"),
1230 RbacDecision::Deny
1231 );
1232 }
1233
1234 #[test]
1235 fn deploy_allowed_on_matching_hosts() {
1236 let policy = test_policy();
1237 assert_eq!(
1238 policy.check("deploy", "resource_run", "web-prod-1"),
1239 RbacDecision::Allow
1240 );
1241 assert_eq!(
1242 policy.check("deploy", "resource_start", "api-staging"),
1243 RbacDecision::Allow
1244 );
1245 }
1246
1247 #[test]
1248 fn deploy_denied_on_non_matching_host() {
1249 let policy = test_policy();
1250 assert_eq!(
1251 policy.check("deploy", "resource_run", "db-prod-1"),
1252 RbacDecision::Deny
1253 );
1254 }
1255
1256 #[test]
1257 fn deny_overrides_allow() {
1258 let policy = test_policy();
1259 assert_eq!(
1260 policy.check("deploy", "resource_delete", "web-prod-1"),
1261 RbacDecision::Deny
1262 );
1263 assert_eq!(
1264 policy.check("deploy", "resource_exec", "web-prod-1"),
1265 RbacDecision::Deny
1266 );
1267 }
1268
1269 #[test]
1270 fn ops_wildcard_allows_everything() {
1271 let policy = test_policy();
1272 assert_eq!(
1273 policy.check("ops", "resource_delete", "any-host"),
1274 RbacDecision::Allow
1275 );
1276 assert_eq!(
1277 policy.check("ops", "secret_create", "db-host"),
1278 RbacDecision::Allow
1279 );
1280 }
1281
1282 #[test]
1285 fn host_visible_respects_globs() {
1286 let policy = test_policy();
1287 assert!(policy.host_visible("deploy", "web-prod-1"));
1288 assert!(policy.host_visible("deploy", "api-staging"));
1289 assert!(!policy.host_visible("deploy", "db-prod-1"));
1290 assert!(policy.host_visible("ops", "anything"));
1291 assert!(policy.host_visible("viewer", "anything"));
1292 }
1293
1294 #[test]
1295 fn host_visible_unknown_role() {
1296 let policy = test_policy();
1297 assert!(!policy.host_visible("unknown", "web-prod-1"));
1298 }
1299
1300 #[test]
1303 fn argument_allowed_no_allowlist() {
1304 let policy = test_policy();
1305 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1307 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1308 }
1309
1310 #[test]
1311 fn argument_allowed_with_allowlist() {
1312 let policy = test_policy();
1313 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1314 assert!(policy.argument_allowed(
1315 "restricted-exec",
1316 "resource_exec",
1317 "cmd",
1318 "bash -c 'echo hi'"
1319 ));
1320 assert!(policy.argument_allowed(
1321 "restricted-exec",
1322 "resource_exec",
1323 "cmd",
1324 "cat /etc/hosts"
1325 ));
1326 assert!(policy.argument_allowed(
1327 "restricted-exec",
1328 "resource_exec",
1329 "cmd",
1330 "/usr/bin/ls -la"
1331 ));
1332 }
1333
1334 #[test]
1335 fn argument_denied_not_in_allowlist() {
1336 let policy = test_policy();
1337 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1338 assert!(!policy.argument_allowed(
1339 "restricted-exec",
1340 "resource_exec",
1341 "cmd",
1342 "python3 exploit.py"
1343 ));
1344 assert!(!policy.argument_allowed(
1345 "restricted-exec",
1346 "resource_exec",
1347 "cmd",
1348 "/usr/bin/curl evil.com"
1349 ));
1350 }
1351
1352 #[test]
1353 fn argument_denied_unknown_role() {
1354 let policy = test_policy();
1355 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1356 }
1357
1358 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1367 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1368 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1369 let mut config = RbacConfig::with_roles(vec![role]);
1370 config.enabled = true;
1371 RbacPolicy::new(&config)
1372 }
1373
1374 #[test]
1375 fn argument_allowed_matches_quoted_path_with_spaces() {
1376 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1377 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1378 }
1379
1380 #[test]
1381 fn argument_allowed_matches_basename_of_quoted_path() {
1382 let policy = shlex_policy(vec!["my tool".into()]);
1383 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1384 }
1385
1386 #[test]
1387 fn argument_allowed_fails_closed_on_unbalanced_quote() {
1388 let policy = shlex_policy(vec!["unbalanced".into()]);
1389 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1390 }
1391
1392 #[test]
1393 fn argument_allowed_fails_closed_on_empty_string() {
1394 let policy = shlex_policy(vec![String::new()]);
1395 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1396 }
1397
1398 #[test]
1399 fn argument_allowed_handles_single_quoted_executable() {
1400 let policy = shlex_policy(vec!["/bin/sh".into()]);
1401 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1402 }
1403
1404 #[test]
1405 fn argument_allowed_handles_tab_separator() {
1406 let policy = shlex_policy(vec!["ls".into()]);
1407 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1408 }
1409
1410 #[test]
1411 fn argument_allowed_plain_token_unchanged() {
1412 let policy = shlex_policy(vec!["ls".into()]);
1413 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1414 }
1415
1416 #[test]
1422 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1423 let policy = shlex_policy(vec![String::new()]);
1427 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1428 }
1429
1430 #[test]
1431 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1432 let policy = shlex_policy(vec!["'bash'".into()]);
1438 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1439 }
1440
1441 #[test]
1442 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1443 let policy = shlex_policy(vec![r"foo\bar".into()]);
1448 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1449 }
1450
1451 #[test]
1452 fn argument_allowed_windows_path_no_longer_matches() {
1453 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1458 assert!(!policy.argument_allowed(
1459 "viewer",
1460 "run",
1461 "cmd",
1462 r"C:\Windows\System32\cmd.exe /c dir"
1463 ));
1464 }
1465
1466 #[test]
1469 fn host_patterns_returns_globs() {
1470 let policy = test_policy();
1471 assert_eq!(
1472 policy.host_patterns("deploy"),
1473 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1474 );
1475 assert_eq!(
1476 policy.host_patterns("ops"),
1477 Some(vec!["*".to_owned()].as_slice())
1478 );
1479 assert!(policy.host_patterns("nonexistent").is_none());
1480 }
1481
1482 #[test]
1485 fn check_operation_allows_without_host() {
1486 let policy = test_policy();
1487 assert_eq!(
1488 policy.check_operation("deploy", "resource_run"),
1489 RbacDecision::Allow
1490 );
1491 assert_eq!(
1493 policy.check("deploy", "resource_run", "db-prod-1"),
1494 RbacDecision::Deny
1495 );
1496 }
1497
1498 #[test]
1499 fn check_operation_deny_overrides() {
1500 let policy = test_policy();
1501 assert_eq!(
1502 policy.check_operation("deploy", "resource_delete"),
1503 RbacDecision::Deny
1504 );
1505 }
1506
1507 #[test]
1508 fn check_operation_unknown_role() {
1509 let policy = test_policy();
1510 assert_eq!(
1511 policy.check_operation("unknown", "resource_list"),
1512 RbacDecision::Deny
1513 );
1514 }
1515
1516 #[test]
1517 fn check_operation_disabled() {
1518 let policy = RbacPolicy::new(&RbacConfig {
1519 enabled: false,
1520 roles: vec![],
1521 redaction_salt: None,
1522 });
1523 assert_eq!(
1524 policy.check_operation("nonexistent", "anything"),
1525 RbacDecision::Allow
1526 );
1527 }
1528
1529 #[test]
1532 fn current_role_returns_none_outside_scope() {
1533 assert!(current_role().is_none());
1534 }
1535
1536 #[test]
1537 fn current_identity_returns_none_outside_scope() {
1538 assert!(current_identity().is_none());
1539 }
1540
1541 use axum::{
1544 body::Body,
1545 http::{Method, Request, StatusCode},
1546 };
1547 use tower::ServiceExt as _;
1548
1549 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1550 serde_json::json!({
1551 "jsonrpc": "2.0",
1552 "id": 1,
1553 "method": "tools/call",
1554 "params": {
1555 "name": tool,
1556 "arguments": args
1557 }
1558 })
1559 .to_string()
1560 }
1561
1562 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1563 axum::Router::new()
1564 .route("/mcp", axum::routing::post(|| async { "ok" }))
1565 .layer(axum::middleware::from_fn(move |req, next| {
1566 let p = Arc::clone(&policy);
1567 rbac_middleware(p, None, req, next)
1568 }))
1569 }
1570
1571 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1572 axum::Router::new()
1573 .route("/mcp", axum::routing::post(|| async { "ok" }))
1574 .layer(axum::middleware::from_fn(
1575 move |mut req: Request<Body>, next: Next| {
1576 let p = Arc::clone(&policy);
1577 let id = identity.clone();
1578 async move {
1579 req.extensions_mut().insert(id);
1580 rbac_middleware(p, None, req, next).await
1581 }
1582 },
1583 ))
1584 }
1585
1586 #[tokio::test]
1587 async fn middleware_passes_non_post() {
1588 let policy = Arc::new(test_policy());
1589 let app = rbac_router(policy);
1590 let req = Request::builder()
1592 .method(Method::GET)
1593 .uri("/mcp")
1594 .body(Body::empty())
1595 .unwrap();
1596 let resp = app.oneshot(req).await.unwrap();
1599 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1600 }
1601
1602 #[tokio::test]
1603 async fn middleware_denies_without_identity() {
1604 let policy = Arc::new(test_policy());
1605 let app = rbac_router(policy);
1606 let body = tool_call_body("resource_list", &serde_json::json!({}));
1607 let req = Request::builder()
1608 .method(Method::POST)
1609 .uri("/mcp")
1610 .header("content-type", "application/json")
1611 .body(Body::from(body))
1612 .unwrap();
1613 let resp = app.oneshot(req).await.unwrap();
1614 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1615 }
1616
1617 #[tokio::test]
1618 async fn middleware_allows_permitted_tool() {
1619 let policy = Arc::new(test_policy());
1620 let id = AuthIdentity {
1621 method: crate::auth::AuthMethod::BearerToken,
1622 name: "alice".into(),
1623 role: "viewer".into(),
1624 raw_token: None,
1625 sub: None,
1626 };
1627 let app = rbac_router_with_identity(policy, id);
1628 let body = tool_call_body("resource_list", &serde_json::json!({}));
1629 let req = Request::builder()
1630 .method(Method::POST)
1631 .uri("/mcp")
1632 .header("content-type", "application/json")
1633 .body(Body::from(body))
1634 .unwrap();
1635 let resp = app.oneshot(req).await.unwrap();
1636 assert_eq!(resp.status(), StatusCode::OK);
1637 }
1638
1639 #[tokio::test]
1640 async fn middleware_denies_unpermitted_tool() {
1641 let policy = Arc::new(test_policy());
1642 let id = AuthIdentity {
1643 method: crate::auth::AuthMethod::BearerToken,
1644 name: "alice".into(),
1645 role: "viewer".into(),
1646 raw_token: None,
1647 sub: None,
1648 };
1649 let app = rbac_router_with_identity(policy, id);
1650 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1651 let req = Request::builder()
1652 .method(Method::POST)
1653 .uri("/mcp")
1654 .header("content-type", "application/json")
1655 .body(Body::from(body))
1656 .unwrap();
1657 let resp = app.oneshot(req).await.unwrap();
1658 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1659 }
1660
1661 #[tokio::test]
1662 async fn middleware_passes_non_tool_call_post() {
1663 let policy = Arc::new(test_policy());
1664 let id = AuthIdentity {
1665 method: crate::auth::AuthMethod::BearerToken,
1666 name: "alice".into(),
1667 role: "viewer".into(),
1668 raw_token: None,
1669 sub: None,
1670 };
1671 let app = rbac_router_with_identity(policy, id);
1672 let body = serde_json::json!({
1674 "jsonrpc": "2.0",
1675 "id": 1,
1676 "method": "resources/list"
1677 })
1678 .to_string();
1679 let req = Request::builder()
1680 .method(Method::POST)
1681 .uri("/mcp")
1682 .header("content-type", "application/json")
1683 .body(Body::from(body))
1684 .unwrap();
1685 let resp = app.oneshot(req).await.unwrap();
1686 assert_eq!(resp.status(), StatusCode::OK);
1687 }
1688
1689 #[tokio::test]
1690 async fn middleware_enforces_argument_allowlist() {
1691 let policy = Arc::new(test_policy());
1692 let id = AuthIdentity {
1693 method: crate::auth::AuthMethod::BearerToken,
1694 name: "dev".into(),
1695 role: "restricted-exec".into(),
1696 raw_token: None,
1697 sub: None,
1698 };
1699 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1701 let body = tool_call_body(
1702 "resource_exec",
1703 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1704 );
1705 let req = Request::builder()
1706 .method(Method::POST)
1707 .uri("/mcp")
1708 .body(Body::from(body))
1709 .unwrap();
1710 let resp = app.oneshot(req).await.unwrap();
1711 assert_eq!(resp.status(), StatusCode::OK);
1712
1713 let app = rbac_router_with_identity(policy, id);
1715 let body = tool_call_body(
1716 "resource_exec",
1717 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1718 );
1719 let req = Request::builder()
1720 .method(Method::POST)
1721 .uri("/mcp")
1722 .body(Body::from(body))
1723 .unwrap();
1724 let resp = app.oneshot(req).await.unwrap();
1725 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1726 }
1727
1728 #[tokio::test]
1729 async fn middleware_disabled_policy_passes_everything() {
1730 let policy = Arc::new(RbacPolicy::disabled());
1731 let app = rbac_router(policy);
1732 let body = tool_call_body("anything", &serde_json::json!({}));
1734 let req = Request::builder()
1735 .method(Method::POST)
1736 .uri("/mcp")
1737 .body(Body::from(body))
1738 .unwrap();
1739 let resp = app.oneshot(req).await.unwrap();
1740 assert_eq!(resp.status(), StatusCode::OK);
1741 }
1742
1743 #[tokio::test]
1744 async fn middleware_batch_all_allowed_passes() {
1745 let policy = Arc::new(test_policy());
1746 let id = AuthIdentity {
1747 method: crate::auth::AuthMethod::BearerToken,
1748 name: "alice".into(),
1749 role: "viewer".into(),
1750 raw_token: None,
1751 sub: None,
1752 };
1753 let app = rbac_router_with_identity(policy, id);
1754 let body = serde_json::json!([
1755 {
1756 "jsonrpc": "2.0",
1757 "id": 1,
1758 "method": "tools/call",
1759 "params": { "name": "resource_list", "arguments": {} }
1760 },
1761 {
1762 "jsonrpc": "2.0",
1763 "id": 2,
1764 "method": "tools/call",
1765 "params": { "name": "system_info", "arguments": {} }
1766 }
1767 ])
1768 .to_string();
1769 let req = Request::builder()
1770 .method(Method::POST)
1771 .uri("/mcp")
1772 .header("content-type", "application/json")
1773 .body(Body::from(body))
1774 .unwrap();
1775 let resp = app.oneshot(req).await.unwrap();
1776 assert_eq!(resp.status(), StatusCode::OK);
1777 }
1778
1779 #[tokio::test]
1780 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
1781 let policy = Arc::new(test_policy());
1782 let id = AuthIdentity {
1783 method: crate::auth::AuthMethod::BearerToken,
1784 name: "alice".into(),
1785 role: "viewer".into(),
1786 raw_token: None,
1787 sub: None,
1788 };
1789 let app = rbac_router_with_identity(policy, id);
1790 let body = serde_json::json!([
1791 {
1792 "jsonrpc": "2.0",
1793 "id": 1,
1794 "method": "tools/call",
1795 "params": { "name": "resource_list", "arguments": {} }
1796 },
1797 {
1798 "jsonrpc": "2.0",
1799 "id": 2,
1800 "method": "tools/call",
1801 "params": { "name": "resource_delete", "arguments": {} }
1802 }
1803 ])
1804 .to_string();
1805 let req = Request::builder()
1806 .method(Method::POST)
1807 .uri("/mcp")
1808 .header("content-type", "application/json")
1809 .body(Body::from(body))
1810 .unwrap();
1811 let resp = app.oneshot(req).await.unwrap();
1812 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1813 }
1814
1815 #[tokio::test]
1816 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1817 let policy = Arc::new(test_policy());
1818 let id = AuthIdentity {
1819 method: crate::auth::AuthMethod::BearerToken,
1820 name: "dev".into(),
1821 role: "restricted-exec".into(),
1822 raw_token: None,
1823 sub: None,
1824 };
1825 let app = rbac_router_with_identity(policy, id);
1826 let body = serde_json::json!([
1827 {
1828 "jsonrpc": "2.0",
1829 "id": 1,
1830 "method": "tools/call",
1831 "params": {
1832 "name": "resource_exec",
1833 "arguments": { "cmd": "ls -la", "host": "dev-1" }
1834 }
1835 },
1836 {
1837 "jsonrpc": "2.0",
1838 "id": 2,
1839 "method": "tools/call",
1840 "params": {
1841 "name": "resource_exec",
1842 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1843 }
1844 }
1845 ])
1846 .to_string();
1847 let req = Request::builder()
1848 .method(Method::POST)
1849 .uri("/mcp")
1850 .header("content-type", "application/json")
1851 .body(Body::from(body))
1852 .unwrap();
1853 let resp = app.oneshot(req).await.unwrap();
1854 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1855 }
1856
1857 #[test]
1860 fn redact_with_salt_is_deterministic_per_salt() {
1861 let salt = b"unit-test-salt";
1862 let a = redact_with_salt(salt, "rm -rf /");
1863 let b = redact_with_salt(salt, "rm -rf /");
1864 assert_eq!(a, b, "same input + salt must yield identical hash");
1865 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1866 assert!(
1867 a.chars().all(|c| c.is_ascii_hexdigit()),
1868 "redacted hash must be lowercase hex: {a}"
1869 );
1870 }
1871
1872 #[test]
1873 fn redact_with_salt_differs_across_salts() {
1874 let v = "the-same-value";
1875 let h1 = redact_with_salt(b"salt-one", v);
1876 let h2 = redact_with_salt(b"salt-two", v);
1877 assert_ne!(
1878 h1, h2,
1879 "different salts must produce different hashes for the same value"
1880 );
1881 }
1882
1883 #[test]
1884 fn redact_with_salt_distinguishes_values() {
1885 let salt = b"k";
1886 let h1 = redact_with_salt(salt, "alpha");
1887 let h2 = redact_with_salt(salt, "beta");
1888 assert_ne!(h1, h2, "different values must produce different hashes");
1890 }
1891
1892 #[test]
1893 fn policy_with_configured_salt_redacts_consistently() {
1894 let cfg = RbacConfig {
1895 enabled: true,
1896 roles: vec![],
1897 redaction_salt: Some(SecretString::from("my-stable-salt")),
1898 };
1899 let p1 = RbacPolicy::new(&cfg);
1900 let p2 = RbacPolicy::new(&cfg);
1901 assert_eq!(
1902 p1.redact_arg("payload"),
1903 p2.redact_arg("payload"),
1904 "policies built from the same configured salt must agree"
1905 );
1906 }
1907
1908 #[test]
1909 fn policy_without_configured_salt_uses_process_salt() {
1910 let cfg = RbacConfig {
1911 enabled: true,
1912 roles: vec![],
1913 redaction_salt: None,
1914 };
1915 let p1 = RbacPolicy::new(&cfg);
1916 let p2 = RbacPolicy::new(&cfg);
1917 assert_eq!(
1919 p1.redact_arg("payload"),
1920 p2.redact_arg("payload"),
1921 "process-wide salt must be consistent within one process"
1922 );
1923 }
1924
1925 #[test]
1926 fn redact_arg_is_fast_enough() {
1927 let salt = b"perf-sanity-salt-32-bytes-padded";
1931 let value = "x".repeat(256);
1932 let start = std::time::Instant::now();
1933 let _ = redact_with_salt(salt, &value);
1934 let elapsed = start.elapsed();
1935 assert!(
1936 elapsed < Duration::from_millis(5),
1937 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
1938 );
1939 }
1940
1941 #[tokio::test]
1953 async fn deny_path_uses_explicit_identity_not_task_local() {
1954 let policy = Arc::new(test_policy());
1955 let id = AuthIdentity {
1956 method: crate::auth::AuthMethod::BearerToken,
1957 name: "alice-the-auditor".into(),
1958 role: "viewer".into(),
1959 raw_token: None,
1960 sub: None,
1961 };
1962 let app = rbac_router_with_identity(policy, id);
1963 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1965 let req = Request::builder()
1966 .method(Method::POST)
1967 .uri("/mcp")
1968 .header("content-type", "application/json")
1969 .body(Body::from(body))
1970 .unwrap();
1971 let resp = app.oneshot(req).await.unwrap();
1972 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1973 }
1974
1975 fn restricted_exec_identity() -> AuthIdentity {
1978 AuthIdentity {
1979 method: crate::auth::AuthMethod::BearerToken,
1980 name: "carol".into(),
1981 role: "restricted-exec".into(),
1982 raw_token: None,
1983 sub: None,
1984 }
1985 }
1986
1987 #[test]
1988 fn has_argument_allowlist_matches_configured_tool_argument() {
1989 let policy = test_policy();
1990 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
1991 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
1992 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
1993 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
1994 }
1995
1996 #[tokio::test]
1997 async fn array_arg_with_matching_allowlist_is_denied() {
1998 let policy = Arc::new(test_policy());
1999 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2000 let body = tool_call_body(
2001 "resource_exec",
2002 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2003 );
2004 let req = Request::builder()
2005 .method(Method::POST)
2006 .uri("/mcp")
2007 .header("content-type", "application/json")
2008 .body(Body::from(body))
2009 .unwrap();
2010 let resp = app.oneshot(req).await.unwrap();
2011 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2012 }
2013
2014 #[tokio::test]
2015 async fn object_arg_with_matching_allowlist_is_denied() {
2016 let policy = Arc::new(test_policy());
2017 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2018 let body = tool_call_body(
2019 "resource_exec",
2020 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2021 );
2022 let req = Request::builder()
2023 .method(Method::POST)
2024 .uri("/mcp")
2025 .header("content-type", "application/json")
2026 .body(Body::from(body))
2027 .unwrap();
2028 let resp = app.oneshot(req).await.unwrap();
2029 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2030 }
2031
2032 #[tokio::test]
2033 async fn number_arg_with_matching_allowlist_is_denied() {
2034 let policy = Arc::new(test_policy());
2035 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2036 let body = tool_call_body(
2037 "resource_exec",
2038 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2039 );
2040 let req = Request::builder()
2041 .method(Method::POST)
2042 .uri("/mcp")
2043 .header("content-type", "application/json")
2044 .body(Body::from(body))
2045 .unwrap();
2046 let resp = app.oneshot(req).await.unwrap();
2047 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2048 }
2049
2050 #[tokio::test]
2051 async fn bool_arg_with_matching_allowlist_is_denied() {
2052 let policy = Arc::new(test_policy());
2053 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2054 let body = tool_call_body(
2055 "resource_exec",
2056 &serde_json::json!({ "host": "dev-1", "cmd": true }),
2057 );
2058 let req = Request::builder()
2059 .method(Method::POST)
2060 .uri("/mcp")
2061 .header("content-type", "application/json")
2062 .body(Body::from(body))
2063 .unwrap();
2064 let resp = app.oneshot(req).await.unwrap();
2065 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2066 }
2067
2068 #[tokio::test]
2069 async fn null_arg_with_matching_allowlist_is_denied() {
2070 let policy = Arc::new(test_policy());
2071 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2072 let body = tool_call_body(
2073 "resource_exec",
2074 &serde_json::json!({ "host": "dev-1", "cmd": null }),
2075 );
2076 let req = Request::builder()
2077 .method(Method::POST)
2078 .uri("/mcp")
2079 .header("content-type", "application/json")
2080 .body(Body::from(body))
2081 .unwrap();
2082 let resp = app.oneshot(req).await.unwrap();
2083 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2084 }
2085
2086 #[tokio::test]
2087 async fn non_string_arg_without_allowlist_is_passthrough() {
2088 let policy = Arc::new(test_policy());
2092 let id = AuthIdentity {
2093 method: crate::auth::AuthMethod::BearerToken,
2094 name: "olivia".into(),
2095 role: "ops".into(),
2096 raw_token: None,
2097 sub: None,
2098 };
2099 let app = rbac_router_with_identity(policy, id);
2100 let body = tool_call_body(
2101 "resource_exec",
2102 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2103 );
2104 let req = Request::builder()
2105 .method(Method::POST)
2106 .uri("/mcp")
2107 .header("content-type", "application/json")
2108 .body(Body::from(body))
2109 .unwrap();
2110 let resp = app.oneshot(req).await.unwrap();
2111 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2112 }
2113
2114 #[tokio::test]
2115 async fn string_arg_in_allowlist_still_passes() {
2116 let policy = Arc::new(test_policy());
2117 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2118 let body = tool_call_body(
2119 "resource_exec",
2120 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2121 );
2122 let req = Request::builder()
2123 .method(Method::POST)
2124 .uri("/mcp")
2125 .header("content-type", "application/json")
2126 .body(Body::from(body))
2127 .unwrap();
2128 let resp = app.oneshot(req).await.unwrap();
2129 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2130 }
2131}