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 {
935 let parts: Vec<&str> = pattern.split('*').collect();
936 if parts.len() == 1 {
937 return pattern == text;
939 }
940
941 let mut pos = 0;
942
943 if let Some(&first) = parts.first()
945 && !first.is_empty()
946 {
947 if !text.starts_with(first) {
948 return false;
949 }
950 pos = first.len();
951 }
952
953 if let Some(&last) = parts.last()
955 && !last.is_empty()
956 {
957 if !text.get(pos..).unwrap_or_default().ends_with(last) {
958 return false;
959 }
960 let end = text.len() - last.len();
962 if pos > end {
963 return false;
964 }
965 let middle = text.get(pos..end).unwrap_or_default();
967 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
968 return match_middle(middle, middle_parts);
969 }
970
971 let middle = text.get(pos..).unwrap_or_default();
973 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
974 match_middle(middle, middle_parts)
975}
976
977fn match_middle(mut text: &str, parts: &[&str]) -> bool {
979 for part in parts {
980 if part.is_empty() {
981 continue;
982 }
983 if let Some(idx) = text.find(part) {
984 text = text.get(idx + part.len()..).unwrap_or_default();
985 } else {
986 return false;
987 }
988 }
989 true
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 fn test_policy() -> RbacPolicy {
997 RbacPolicy::new(&RbacConfig {
998 enabled: true,
999 roles: vec![
1000 RoleConfig {
1001 name: "viewer".into(),
1002 description: Some("Read-only".into()),
1003 allow: vec![
1004 "list_hosts".into(),
1005 "resource_list".into(),
1006 "resource_inspect".into(),
1007 "resource_logs".into(),
1008 "system_info".into(),
1009 ],
1010 deny: vec![],
1011 hosts: vec!["*".into()],
1012 argument_allowlists: vec![],
1013 },
1014 RoleConfig {
1015 name: "deploy".into(),
1016 description: Some("Lifecycle management".into()),
1017 allow: vec![
1018 "list_hosts".into(),
1019 "resource_list".into(),
1020 "resource_run".into(),
1021 "resource_start".into(),
1022 "resource_stop".into(),
1023 "resource_restart".into(),
1024 "resource_logs".into(),
1025 "image_pull".into(),
1026 ],
1027 deny: vec!["resource_delete".into(), "resource_exec".into()],
1028 hosts: vec!["web-*".into(), "api-*".into()],
1029 argument_allowlists: vec![],
1030 },
1031 RoleConfig {
1032 name: "ops".into(),
1033 description: Some("Full access".into()),
1034 allow: vec!["*".into()],
1035 deny: vec![],
1036 hosts: vec!["*".into()],
1037 argument_allowlists: vec![],
1038 },
1039 RoleConfig {
1040 name: "restricted-exec".into(),
1041 description: Some("Exec with argument allowlist".into()),
1042 allow: vec!["resource_exec".into()],
1043 deny: vec![],
1044 hosts: vec!["dev-*".into()],
1045 argument_allowlists: vec![ArgumentAllowlist {
1046 tool: "resource_exec".into(),
1047 argument: "cmd".into(),
1048 allowed: vec![
1049 "sh".into(),
1050 "bash".into(),
1051 "cat".into(),
1052 "ls".into(),
1053 "ps".into(),
1054 ],
1055 }],
1056 },
1057 ],
1058 redaction_salt: None,
1059 })
1060 }
1061
1062 #[test]
1065 fn glob_exact_match() {
1066 assert!(glob_match("web-prod-1", "web-prod-1"));
1067 assert!(!glob_match("web-prod-1", "web-prod-2"));
1068 }
1069
1070 #[test]
1071 fn glob_star_suffix() {
1072 assert!(glob_match("web-*", "web-prod-1"));
1073 assert!(glob_match("web-*", "web-staging"));
1074 assert!(!glob_match("web-*", "api-prod"));
1075 }
1076
1077 #[test]
1078 fn glob_star_prefix() {
1079 assert!(glob_match("*-prod", "web-prod"));
1080 assert!(glob_match("*-prod", "api-prod"));
1081 assert!(!glob_match("*-prod", "web-staging"));
1082 }
1083
1084 #[test]
1085 fn glob_star_middle() {
1086 assert!(glob_match("web-*-prod", "web-us-prod"));
1087 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1088 assert!(!glob_match("web-*-prod", "web-staging"));
1089 }
1090
1091 #[test]
1092 fn glob_star_only() {
1093 assert!(glob_match("*", "anything"));
1094 assert!(glob_match("*", ""));
1095 }
1096
1097 #[test]
1098 fn glob_multiple_stars() {
1099 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1100 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1101 }
1102
1103 #[test]
1108 fn glob_match_multibyte_utf8() {
1109 assert!(glob_match("hé*llo", "héllo"));
1110 assert!(glob_match("*ö*", "wörld"));
1111 assert!(glob_match("über*", "übermensch"));
1112 assert!(glob_match("*界", "世界"));
1113 assert!(!glob_match("hé*llo", "hello"));
1114 assert!(!glob_match("界*", "世界"));
1115 assert!(glob_match("世*界", "世界"));
1116 }
1117
1118 #[test]
1130 fn glob_prefix_and_suffix_meet_exactly() {
1131 assert!(glob_match("ab*cd", "abcd"));
1134 }
1135
1136 #[test]
1141 fn glob_middle_segment_required_with_suffix() {
1142 assert!(!glob_match("a*b*c", "axyc"));
1147 }
1148
1149 #[test]
1155 fn glob_match_middle_advances_past_matched_part() {
1156 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1161 }
1162
1163 #[test]
1168 fn glob_match_middle_uses_addition_not_multiplication() {
1169 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1173 }
1174
1175 #[test]
1184 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1185 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1193 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1194 "run-*",
1195 "cmd",
1196 vec!["ls".into()],
1197 )]);
1198 let mut config = RbacConfig::with_roles(vec![role]);
1199 config.enabled = true;
1200 let policy = RbacPolicy::new(&config);
1201 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1202 }
1203
1204 #[test]
1207 fn disabled_policy_allows_everything() {
1208 let policy = RbacPolicy::new(&RbacConfig {
1209 enabled: false,
1210 roles: vec![],
1211 redaction_salt: None,
1212 });
1213 assert_eq!(
1214 policy.check("nonexistent", "resource_delete", "any-host"),
1215 RbacDecision::Allow
1216 );
1217 }
1218
1219 #[test]
1220 fn unknown_role_denied() {
1221 let policy = test_policy();
1222 assert_eq!(
1223 policy.check("unknown", "resource_list", "web-prod-1"),
1224 RbacDecision::Deny
1225 );
1226 }
1227
1228 #[test]
1229 fn viewer_allowed_read_ops() {
1230 let policy = test_policy();
1231 assert_eq!(
1232 policy.check("viewer", "resource_list", "web-prod-1"),
1233 RbacDecision::Allow
1234 );
1235 assert_eq!(
1236 policy.check("viewer", "system_info", "db-host"),
1237 RbacDecision::Allow
1238 );
1239 }
1240
1241 #[test]
1242 fn viewer_denied_write_ops() {
1243 let policy = test_policy();
1244 assert_eq!(
1245 policy.check("viewer", "resource_run", "web-prod-1"),
1246 RbacDecision::Deny
1247 );
1248 assert_eq!(
1249 policy.check("viewer", "resource_delete", "web-prod-1"),
1250 RbacDecision::Deny
1251 );
1252 }
1253
1254 #[test]
1255 fn deploy_allowed_on_matching_hosts() {
1256 let policy = test_policy();
1257 assert_eq!(
1258 policy.check("deploy", "resource_run", "web-prod-1"),
1259 RbacDecision::Allow
1260 );
1261 assert_eq!(
1262 policy.check("deploy", "resource_start", "api-staging"),
1263 RbacDecision::Allow
1264 );
1265 }
1266
1267 #[test]
1268 fn deploy_denied_on_non_matching_host() {
1269 let policy = test_policy();
1270 assert_eq!(
1271 policy.check("deploy", "resource_run", "db-prod-1"),
1272 RbacDecision::Deny
1273 );
1274 }
1275
1276 #[test]
1277 fn deny_overrides_allow() {
1278 let policy = test_policy();
1279 assert_eq!(
1280 policy.check("deploy", "resource_delete", "web-prod-1"),
1281 RbacDecision::Deny
1282 );
1283 assert_eq!(
1284 policy.check("deploy", "resource_exec", "web-prod-1"),
1285 RbacDecision::Deny
1286 );
1287 }
1288
1289 #[test]
1290 fn ops_wildcard_allows_everything() {
1291 let policy = test_policy();
1292 assert_eq!(
1293 policy.check("ops", "resource_delete", "any-host"),
1294 RbacDecision::Allow
1295 );
1296 assert_eq!(
1297 policy.check("ops", "secret_create", "db-host"),
1298 RbacDecision::Allow
1299 );
1300 }
1301
1302 #[test]
1305 fn host_visible_respects_globs() {
1306 let policy = test_policy();
1307 assert!(policy.host_visible("deploy", "web-prod-1"));
1308 assert!(policy.host_visible("deploy", "api-staging"));
1309 assert!(!policy.host_visible("deploy", "db-prod-1"));
1310 assert!(policy.host_visible("ops", "anything"));
1311 assert!(policy.host_visible("viewer", "anything"));
1312 }
1313
1314 #[test]
1315 fn host_visible_unknown_role() {
1316 let policy = test_policy();
1317 assert!(!policy.host_visible("unknown", "web-prod-1"));
1318 }
1319
1320 #[test]
1323 fn argument_allowed_no_allowlist() {
1324 let policy = test_policy();
1325 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1327 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1328 }
1329
1330 #[test]
1331 fn argument_allowed_with_allowlist() {
1332 let policy = test_policy();
1333 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1334 assert!(policy.argument_allowed(
1335 "restricted-exec",
1336 "resource_exec",
1337 "cmd",
1338 "bash -c 'echo hi'"
1339 ));
1340 assert!(policy.argument_allowed(
1341 "restricted-exec",
1342 "resource_exec",
1343 "cmd",
1344 "cat /etc/hosts"
1345 ));
1346 assert!(policy.argument_allowed(
1347 "restricted-exec",
1348 "resource_exec",
1349 "cmd",
1350 "/usr/bin/ls -la"
1351 ));
1352 }
1353
1354 #[test]
1355 fn argument_denied_not_in_allowlist() {
1356 let policy = test_policy();
1357 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1358 assert!(!policy.argument_allowed(
1359 "restricted-exec",
1360 "resource_exec",
1361 "cmd",
1362 "python3 exploit.py"
1363 ));
1364 assert!(!policy.argument_allowed(
1365 "restricted-exec",
1366 "resource_exec",
1367 "cmd",
1368 "/usr/bin/curl evil.com"
1369 ));
1370 }
1371
1372 #[test]
1373 fn argument_denied_unknown_role() {
1374 let policy = test_policy();
1375 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1376 }
1377
1378 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1387 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1388 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1389 let mut config = RbacConfig::with_roles(vec![role]);
1390 config.enabled = true;
1391 RbacPolicy::new(&config)
1392 }
1393
1394 #[test]
1395 fn argument_allowed_matches_quoted_path_with_spaces() {
1396 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1397 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1398 }
1399
1400 #[test]
1401 fn argument_allowed_matches_basename_of_quoted_path() {
1402 let policy = shlex_policy(vec!["my tool".into()]);
1403 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1404 }
1405
1406 #[test]
1407 fn argument_allowed_fails_closed_on_unbalanced_quote() {
1408 let policy = shlex_policy(vec!["unbalanced".into()]);
1409 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1410 }
1411
1412 #[test]
1413 fn argument_allowed_fails_closed_on_empty_string() {
1414 let policy = shlex_policy(vec![String::new()]);
1415 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1416 }
1417
1418 #[test]
1419 fn argument_allowed_handles_single_quoted_executable() {
1420 let policy = shlex_policy(vec!["/bin/sh".into()]);
1421 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1422 }
1423
1424 #[test]
1425 fn argument_allowed_handles_tab_separator() {
1426 let policy = shlex_policy(vec!["ls".into()]);
1427 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1428 }
1429
1430 #[test]
1431 fn argument_allowed_plain_token_unchanged() {
1432 let policy = shlex_policy(vec!["ls".into()]);
1433 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1434 }
1435
1436 #[test]
1442 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1443 let policy = shlex_policy(vec![String::new()]);
1447 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1448 }
1449
1450 #[test]
1451 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1452 let policy = shlex_policy(vec!["'bash'".into()]);
1458 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1459 }
1460
1461 #[test]
1462 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1463 let policy = shlex_policy(vec![r"foo\bar".into()]);
1468 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1469 }
1470
1471 #[test]
1472 fn argument_allowed_windows_path_no_longer_matches() {
1473 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1478 assert!(!policy.argument_allowed(
1479 "viewer",
1480 "run",
1481 "cmd",
1482 r"C:\Windows\System32\cmd.exe /c dir"
1483 ));
1484 }
1485
1486 #[test]
1489 fn host_patterns_returns_globs() {
1490 let policy = test_policy();
1491 assert_eq!(
1492 policy.host_patterns("deploy"),
1493 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1494 );
1495 assert_eq!(
1496 policy.host_patterns("ops"),
1497 Some(vec!["*".to_owned()].as_slice())
1498 );
1499 assert!(policy.host_patterns("nonexistent").is_none());
1500 }
1501
1502 #[test]
1505 fn check_operation_allows_without_host() {
1506 let policy = test_policy();
1507 assert_eq!(
1508 policy.check_operation("deploy", "resource_run"),
1509 RbacDecision::Allow
1510 );
1511 assert_eq!(
1513 policy.check("deploy", "resource_run", "db-prod-1"),
1514 RbacDecision::Deny
1515 );
1516 }
1517
1518 #[test]
1519 fn check_operation_deny_overrides() {
1520 let policy = test_policy();
1521 assert_eq!(
1522 policy.check_operation("deploy", "resource_delete"),
1523 RbacDecision::Deny
1524 );
1525 }
1526
1527 #[test]
1528 fn check_operation_unknown_role() {
1529 let policy = test_policy();
1530 assert_eq!(
1531 policy.check_operation("unknown", "resource_list"),
1532 RbacDecision::Deny
1533 );
1534 }
1535
1536 #[test]
1537 fn check_operation_disabled() {
1538 let policy = RbacPolicy::new(&RbacConfig {
1539 enabled: false,
1540 roles: vec![],
1541 redaction_salt: None,
1542 });
1543 assert_eq!(
1544 policy.check_operation("nonexistent", "anything"),
1545 RbacDecision::Allow
1546 );
1547 }
1548
1549 #[test]
1552 fn current_role_returns_none_outside_scope() {
1553 assert!(current_role().is_none());
1554 }
1555
1556 #[test]
1557 fn current_identity_returns_none_outside_scope() {
1558 assert!(current_identity().is_none());
1559 }
1560
1561 use axum::{
1564 body::Body,
1565 http::{Method, Request, StatusCode},
1566 };
1567 use tower::ServiceExt as _;
1568
1569 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1570 serde_json::json!({
1571 "jsonrpc": "2.0",
1572 "id": 1,
1573 "method": "tools/call",
1574 "params": {
1575 "name": tool,
1576 "arguments": args
1577 }
1578 })
1579 .to_string()
1580 }
1581
1582 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1583 axum::Router::new()
1584 .route("/mcp", axum::routing::post(|| async { "ok" }))
1585 .layer(axum::middleware::from_fn(move |req, next| {
1586 let p = Arc::clone(&policy);
1587 rbac_middleware(p, None, req, next)
1588 }))
1589 }
1590
1591 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1592 axum::Router::new()
1593 .route("/mcp", axum::routing::post(|| async { "ok" }))
1594 .layer(axum::middleware::from_fn(
1595 move |mut req: Request<Body>, next: Next| {
1596 let p = Arc::clone(&policy);
1597 let id = identity.clone();
1598 async move {
1599 req.extensions_mut().insert(id);
1600 rbac_middleware(p, None, req, next).await
1601 }
1602 },
1603 ))
1604 }
1605
1606 #[tokio::test]
1607 async fn middleware_passes_non_post() {
1608 let policy = Arc::new(test_policy());
1609 let app = rbac_router(policy);
1610 let req = Request::builder()
1612 .method(Method::GET)
1613 .uri("/mcp")
1614 .body(Body::empty())
1615 .unwrap();
1616 let resp = app.oneshot(req).await.unwrap();
1619 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1620 }
1621
1622 #[tokio::test]
1623 async fn middleware_denies_without_identity() {
1624 let policy = Arc::new(test_policy());
1625 let app = rbac_router(policy);
1626 let body = tool_call_body("resource_list", &serde_json::json!({}));
1627 let req = Request::builder()
1628 .method(Method::POST)
1629 .uri("/mcp")
1630 .header("content-type", "application/json")
1631 .body(Body::from(body))
1632 .unwrap();
1633 let resp = app.oneshot(req).await.unwrap();
1634 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1635 }
1636
1637 #[tokio::test]
1638 async fn middleware_allows_permitted_tool() {
1639 let policy = Arc::new(test_policy());
1640 let id = AuthIdentity {
1641 method: crate::auth::AuthMethod::BearerToken,
1642 name: "alice".into(),
1643 role: "viewer".into(),
1644 raw_token: None,
1645 sub: None,
1646 };
1647 let app = rbac_router_with_identity(policy, id);
1648 let body = tool_call_body("resource_list", &serde_json::json!({}));
1649 let req = Request::builder()
1650 .method(Method::POST)
1651 .uri("/mcp")
1652 .header("content-type", "application/json")
1653 .body(Body::from(body))
1654 .unwrap();
1655 let resp = app.oneshot(req).await.unwrap();
1656 assert_eq!(resp.status(), StatusCode::OK);
1657 }
1658
1659 #[tokio::test]
1660 async fn middleware_denies_unpermitted_tool() {
1661 let policy = Arc::new(test_policy());
1662 let id = AuthIdentity {
1663 method: crate::auth::AuthMethod::BearerToken,
1664 name: "alice".into(),
1665 role: "viewer".into(),
1666 raw_token: None,
1667 sub: None,
1668 };
1669 let app = rbac_router_with_identity(policy, id);
1670 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1671 let req = Request::builder()
1672 .method(Method::POST)
1673 .uri("/mcp")
1674 .header("content-type", "application/json")
1675 .body(Body::from(body))
1676 .unwrap();
1677 let resp = app.oneshot(req).await.unwrap();
1678 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1679 }
1680
1681 #[tokio::test]
1682 async fn middleware_passes_non_tool_call_post() {
1683 let policy = Arc::new(test_policy());
1684 let id = AuthIdentity {
1685 method: crate::auth::AuthMethod::BearerToken,
1686 name: "alice".into(),
1687 role: "viewer".into(),
1688 raw_token: None,
1689 sub: None,
1690 };
1691 let app = rbac_router_with_identity(policy, id);
1692 let body = serde_json::json!({
1694 "jsonrpc": "2.0",
1695 "id": 1,
1696 "method": "resources/list"
1697 })
1698 .to_string();
1699 let req = Request::builder()
1700 .method(Method::POST)
1701 .uri("/mcp")
1702 .header("content-type", "application/json")
1703 .body(Body::from(body))
1704 .unwrap();
1705 let resp = app.oneshot(req).await.unwrap();
1706 assert_eq!(resp.status(), StatusCode::OK);
1707 }
1708
1709 #[tokio::test]
1710 async fn middleware_enforces_argument_allowlist() {
1711 let policy = Arc::new(test_policy());
1712 let id = AuthIdentity {
1713 method: crate::auth::AuthMethod::BearerToken,
1714 name: "dev".into(),
1715 role: "restricted-exec".into(),
1716 raw_token: None,
1717 sub: None,
1718 };
1719 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1721 let body = tool_call_body(
1722 "resource_exec",
1723 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1724 );
1725 let req = Request::builder()
1726 .method(Method::POST)
1727 .uri("/mcp")
1728 .body(Body::from(body))
1729 .unwrap();
1730 let resp = app.oneshot(req).await.unwrap();
1731 assert_eq!(resp.status(), StatusCode::OK);
1732
1733 let app = rbac_router_with_identity(policy, id);
1735 let body = tool_call_body(
1736 "resource_exec",
1737 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1738 );
1739 let req = Request::builder()
1740 .method(Method::POST)
1741 .uri("/mcp")
1742 .body(Body::from(body))
1743 .unwrap();
1744 let resp = app.oneshot(req).await.unwrap();
1745 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1746 }
1747
1748 #[tokio::test]
1749 async fn middleware_disabled_policy_passes_everything() {
1750 let policy = Arc::new(RbacPolicy::disabled());
1751 let app = rbac_router(policy);
1752 let body = tool_call_body("anything", &serde_json::json!({}));
1754 let req = Request::builder()
1755 .method(Method::POST)
1756 .uri("/mcp")
1757 .body(Body::from(body))
1758 .unwrap();
1759 let resp = app.oneshot(req).await.unwrap();
1760 assert_eq!(resp.status(), StatusCode::OK);
1761 }
1762
1763 #[tokio::test]
1764 async fn middleware_batch_all_allowed_passes() {
1765 let policy = Arc::new(test_policy());
1766 let id = AuthIdentity {
1767 method: crate::auth::AuthMethod::BearerToken,
1768 name: "alice".into(),
1769 role: "viewer".into(),
1770 raw_token: None,
1771 sub: None,
1772 };
1773 let app = rbac_router_with_identity(policy, id);
1774 let body = serde_json::json!([
1775 {
1776 "jsonrpc": "2.0",
1777 "id": 1,
1778 "method": "tools/call",
1779 "params": { "name": "resource_list", "arguments": {} }
1780 },
1781 {
1782 "jsonrpc": "2.0",
1783 "id": 2,
1784 "method": "tools/call",
1785 "params": { "name": "system_info", "arguments": {} }
1786 }
1787 ])
1788 .to_string();
1789 let req = Request::builder()
1790 .method(Method::POST)
1791 .uri("/mcp")
1792 .header("content-type", "application/json")
1793 .body(Body::from(body))
1794 .unwrap();
1795 let resp = app.oneshot(req).await.unwrap();
1796 assert_eq!(resp.status(), StatusCode::OK);
1797 }
1798
1799 #[tokio::test]
1800 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
1801 let policy = Arc::new(test_policy());
1802 let id = AuthIdentity {
1803 method: crate::auth::AuthMethod::BearerToken,
1804 name: "alice".into(),
1805 role: "viewer".into(),
1806 raw_token: None,
1807 sub: None,
1808 };
1809 let app = rbac_router_with_identity(policy, id);
1810 let body = serde_json::json!([
1811 {
1812 "jsonrpc": "2.0",
1813 "id": 1,
1814 "method": "tools/call",
1815 "params": { "name": "resource_list", "arguments": {} }
1816 },
1817 {
1818 "jsonrpc": "2.0",
1819 "id": 2,
1820 "method": "tools/call",
1821 "params": { "name": "resource_delete", "arguments": {} }
1822 }
1823 ])
1824 .to_string();
1825 let req = Request::builder()
1826 .method(Method::POST)
1827 .uri("/mcp")
1828 .header("content-type", "application/json")
1829 .body(Body::from(body))
1830 .unwrap();
1831 let resp = app.oneshot(req).await.unwrap();
1832 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1833 }
1834
1835 #[tokio::test]
1836 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1837 let policy = Arc::new(test_policy());
1838 let id = AuthIdentity {
1839 method: crate::auth::AuthMethod::BearerToken,
1840 name: "dev".into(),
1841 role: "restricted-exec".into(),
1842 raw_token: None,
1843 sub: None,
1844 };
1845 let app = rbac_router_with_identity(policy, id);
1846 let body = serde_json::json!([
1847 {
1848 "jsonrpc": "2.0",
1849 "id": 1,
1850 "method": "tools/call",
1851 "params": {
1852 "name": "resource_exec",
1853 "arguments": { "cmd": "ls -la", "host": "dev-1" }
1854 }
1855 },
1856 {
1857 "jsonrpc": "2.0",
1858 "id": 2,
1859 "method": "tools/call",
1860 "params": {
1861 "name": "resource_exec",
1862 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1863 }
1864 }
1865 ])
1866 .to_string();
1867 let req = Request::builder()
1868 .method(Method::POST)
1869 .uri("/mcp")
1870 .header("content-type", "application/json")
1871 .body(Body::from(body))
1872 .unwrap();
1873 let resp = app.oneshot(req).await.unwrap();
1874 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1875 }
1876
1877 #[test]
1880 fn redact_with_salt_is_deterministic_per_salt() {
1881 let salt = b"unit-test-salt";
1882 let a = redact_with_salt(salt, "rm -rf /");
1883 let b = redact_with_salt(salt, "rm -rf /");
1884 assert_eq!(a, b, "same input + salt must yield identical hash");
1885 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1886 assert!(
1887 a.chars().all(|c| c.is_ascii_hexdigit()),
1888 "redacted hash must be lowercase hex: {a}"
1889 );
1890 }
1891
1892 #[test]
1893 fn redact_with_salt_differs_across_salts() {
1894 let v = "the-same-value";
1895 let h1 = redact_with_salt(b"salt-one", v);
1896 let h2 = redact_with_salt(b"salt-two", v);
1897 assert_ne!(
1898 h1, h2,
1899 "different salts must produce different hashes for the same value"
1900 );
1901 }
1902
1903 #[test]
1904 fn redact_with_salt_distinguishes_values() {
1905 let salt = b"k";
1906 let h1 = redact_with_salt(salt, "alpha");
1907 let h2 = redact_with_salt(salt, "beta");
1908 assert_ne!(h1, h2, "different values must produce different hashes");
1910 }
1911
1912 #[test]
1913 fn policy_with_configured_salt_redacts_consistently() {
1914 let cfg = RbacConfig {
1915 enabled: true,
1916 roles: vec![],
1917 redaction_salt: Some(SecretString::from("my-stable-salt")),
1918 };
1919 let p1 = RbacPolicy::new(&cfg);
1920 let p2 = RbacPolicy::new(&cfg);
1921 assert_eq!(
1922 p1.redact_arg("payload"),
1923 p2.redact_arg("payload"),
1924 "policies built from the same configured salt must agree"
1925 );
1926 }
1927
1928 #[test]
1929 fn policy_without_configured_salt_uses_process_salt() {
1930 let cfg = RbacConfig {
1931 enabled: true,
1932 roles: vec![],
1933 redaction_salt: None,
1934 };
1935 let p1 = RbacPolicy::new(&cfg);
1936 let p2 = RbacPolicy::new(&cfg);
1937 assert_eq!(
1939 p1.redact_arg("payload"),
1940 p2.redact_arg("payload"),
1941 "process-wide salt must be consistent within one process"
1942 );
1943 }
1944
1945 #[test]
1946 fn redact_arg_is_fast_enough() {
1947 let salt = b"perf-sanity-salt-32-bytes-padded";
1951 let value = "x".repeat(256);
1952 let start = std::time::Instant::now();
1953 let _ = redact_with_salt(salt, &value);
1954 let elapsed = start.elapsed();
1955 assert!(
1956 elapsed < Duration::from_millis(5),
1957 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
1958 );
1959 }
1960
1961 #[tokio::test]
1973 async fn deny_path_uses_explicit_identity_not_task_local() {
1974 let policy = Arc::new(test_policy());
1975 let id = AuthIdentity {
1976 method: crate::auth::AuthMethod::BearerToken,
1977 name: "alice-the-auditor".into(),
1978 role: "viewer".into(),
1979 raw_token: None,
1980 sub: None,
1981 };
1982 let app = rbac_router_with_identity(policy, id);
1983 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1985 let req = Request::builder()
1986 .method(Method::POST)
1987 .uri("/mcp")
1988 .header("content-type", "application/json")
1989 .body(Body::from(body))
1990 .unwrap();
1991 let resp = app.oneshot(req).await.unwrap();
1992 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1993 }
1994
1995 fn restricted_exec_identity() -> AuthIdentity {
1998 AuthIdentity {
1999 method: crate::auth::AuthMethod::BearerToken,
2000 name: "carol".into(),
2001 role: "restricted-exec".into(),
2002 raw_token: None,
2003 sub: None,
2004 }
2005 }
2006
2007 #[test]
2008 fn has_argument_allowlist_matches_configured_tool_argument() {
2009 let policy = test_policy();
2010 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2011 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2012 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2013 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2014 }
2015
2016 #[tokio::test]
2017 async fn array_arg_with_matching_allowlist_is_denied() {
2018 let policy = Arc::new(test_policy());
2019 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2020 let body = tool_call_body(
2021 "resource_exec",
2022 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2023 );
2024 let req = Request::builder()
2025 .method(Method::POST)
2026 .uri("/mcp")
2027 .header("content-type", "application/json")
2028 .body(Body::from(body))
2029 .unwrap();
2030 let resp = app.oneshot(req).await.unwrap();
2031 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2032 }
2033
2034 #[tokio::test]
2035 async fn object_arg_with_matching_allowlist_is_denied() {
2036 let policy = Arc::new(test_policy());
2037 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2038 let body = tool_call_body(
2039 "resource_exec",
2040 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2041 );
2042 let req = Request::builder()
2043 .method(Method::POST)
2044 .uri("/mcp")
2045 .header("content-type", "application/json")
2046 .body(Body::from(body))
2047 .unwrap();
2048 let resp = app.oneshot(req).await.unwrap();
2049 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2050 }
2051
2052 #[tokio::test]
2053 async fn number_arg_with_matching_allowlist_is_denied() {
2054 let policy = Arc::new(test_policy());
2055 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2056 let body = tool_call_body(
2057 "resource_exec",
2058 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2059 );
2060 let req = Request::builder()
2061 .method(Method::POST)
2062 .uri("/mcp")
2063 .header("content-type", "application/json")
2064 .body(Body::from(body))
2065 .unwrap();
2066 let resp = app.oneshot(req).await.unwrap();
2067 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2068 }
2069
2070 #[tokio::test]
2071 async fn bool_arg_with_matching_allowlist_is_denied() {
2072 let policy = Arc::new(test_policy());
2073 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2074 let body = tool_call_body(
2075 "resource_exec",
2076 &serde_json::json!({ "host": "dev-1", "cmd": true }),
2077 );
2078 let req = Request::builder()
2079 .method(Method::POST)
2080 .uri("/mcp")
2081 .header("content-type", "application/json")
2082 .body(Body::from(body))
2083 .unwrap();
2084 let resp = app.oneshot(req).await.unwrap();
2085 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2086 }
2087
2088 #[tokio::test]
2089 async fn null_arg_with_matching_allowlist_is_denied() {
2090 let policy = Arc::new(test_policy());
2091 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2092 let body = tool_call_body(
2093 "resource_exec",
2094 &serde_json::json!({ "host": "dev-1", "cmd": null }),
2095 );
2096 let req = Request::builder()
2097 .method(Method::POST)
2098 .uri("/mcp")
2099 .header("content-type", "application/json")
2100 .body(Body::from(body))
2101 .unwrap();
2102 let resp = app.oneshot(req).await.unwrap();
2103 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2104 }
2105
2106 #[tokio::test]
2107 async fn non_string_arg_without_allowlist_is_passthrough() {
2108 let policy = Arc::new(test_policy());
2112 let id = AuthIdentity {
2113 method: crate::auth::AuthMethod::BearerToken,
2114 name: "olivia".into(),
2115 role: "ops".into(),
2116 raw_token: None,
2117 sub: None,
2118 };
2119 let app = rbac_router_with_identity(policy, id);
2120 let body = tool_call_body(
2121 "resource_exec",
2122 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2123 );
2124 let req = Request::builder()
2125 .method(Method::POST)
2126 .uri("/mcp")
2127 .header("content-type", "application/json")
2128 .body(Body::from(body))
2129 .unwrap();
2130 let resp = app.oneshot(req).await.unwrap();
2131 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2132 }
2133
2134 #[tokio::test]
2135 async fn string_arg_in_allowlist_still_passes() {
2136 let policy = Arc::new(test_policy());
2137 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2138 let body = tool_call_body(
2139 "resource_exec",
2140 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2141 );
2142 let req = Request::builder()
2143 .method(Method::POST)
2144 .uri("/mcp")
2145 .header("content-type", "application/json")
2146 .body(Body::from(body))
2147 .unwrap();
2148 let resp = app.oneshot(req).await.unwrap();
2149 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2150 }
2151}