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)]
225#[non_exhaustive]
226pub struct ArgumentAllowlist {
227 pub tool: String,
229 pub argument: String,
231 #[serde(default)]
233 pub allowed: Vec<String>,
234}
235
236impl ArgumentAllowlist {
237 #[must_use]
239 pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
240 Self {
241 tool: tool.into(),
242 argument: argument.into(),
243 allowed,
244 }
245 }
246}
247
248fn default_hosts() -> Vec<String> {
249 vec!["*".into()]
250}
251
252#[derive(Debug, Clone, Default, Deserialize)]
254#[non_exhaustive]
255pub struct RbacConfig {
256 #[serde(default)]
258 pub enabled: bool,
259 #[serde(default)]
261 pub roles: Vec<RoleConfig>,
262 #[serde(default)]
271 pub redaction_salt: Option<SecretString>,
272}
273
274impl RbacConfig {
275 #[must_use]
277 pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
278 Self {
279 enabled: true,
280 roles,
281 redaction_salt: None,
282 }
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288#[non_exhaustive]
289pub enum RbacDecision {
290 Allow,
292 Deny,
294}
295
296#[derive(Debug, Clone, serde::Serialize)]
298#[non_exhaustive]
299pub struct RbacRoleSummary {
300 pub name: String,
302 pub allow: usize,
304 pub deny: usize,
306 pub hosts: usize,
308 pub argument_allowlists: usize,
310}
311
312#[derive(Debug, Clone, serde::Serialize)]
314#[non_exhaustive]
315pub struct RbacPolicySummary {
316 pub enabled: bool,
318 pub roles: Vec<RbacRoleSummary>,
320}
321
322#[derive(Debug, Clone)]
328#[non_exhaustive]
329pub struct RbacPolicy {
330 roles: Vec<RoleConfig>,
331 enabled: bool,
332 redaction_salt: Arc<SecretString>,
335}
336
337impl RbacPolicy {
338 #[must_use]
341 pub fn new(config: &RbacConfig) -> Self {
342 let salt = config
343 .redaction_salt
344 .clone()
345 .unwrap_or_else(|| process_redaction_salt().clone());
346 Self {
347 roles: config.roles.clone(),
348 enabled: config.enabled,
349 redaction_salt: Arc::new(salt),
350 }
351 }
352
353 #[must_use]
355 pub fn disabled() -> Self {
356 Self {
357 roles: Vec::new(),
358 enabled: false,
359 redaction_salt: Arc::new(process_redaction_salt().clone()),
360 }
361 }
362
363 #[must_use]
365 pub fn is_enabled(&self) -> bool {
366 self.enabled
367 }
368
369 #[must_use]
374 pub fn summary(&self) -> RbacPolicySummary {
375 let roles = self
376 .roles
377 .iter()
378 .map(|r| RbacRoleSummary {
379 name: r.name.clone(),
380 allow: r.allow.len(),
381 deny: r.deny.len(),
382 hosts: r.hosts.len(),
383 argument_allowlists: r.argument_allowlists.len(),
384 })
385 .collect();
386 RbacPolicySummary {
387 enabled: self.enabled,
388 roles,
389 }
390 }
391
392 #[must_use]
397 pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
398 if !self.enabled {
399 return RbacDecision::Allow;
400 }
401 let Some(role_cfg) = self.find_role(role) else {
402 return RbacDecision::Deny;
403 };
404 if role_cfg.deny.iter().any(|d| d == operation) {
405 return RbacDecision::Deny;
406 }
407 if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
408 return RbacDecision::Allow;
409 }
410 RbacDecision::Deny
411 }
412
413 #[must_use]
420 pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
421 if !self.enabled {
422 return RbacDecision::Allow;
423 }
424 let Some(role_cfg) = self.find_role(role) else {
425 return RbacDecision::Deny;
426 };
427 if role_cfg.deny.iter().any(|d| d == operation) {
428 return RbacDecision::Deny;
429 }
430 if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
431 return RbacDecision::Deny;
432 }
433 if !Self::host_matches(&role_cfg.hosts, host) {
434 return RbacDecision::Deny;
435 }
436 RbacDecision::Allow
437 }
438
439 #[must_use]
441 pub fn host_visible(&self, role: &str, host: &str) -> bool {
442 if !self.enabled {
443 return true;
444 }
445 let Some(role_cfg) = self.find_role(role) else {
446 return false;
447 };
448 Self::host_matches(&role_cfg.hosts, host)
449 }
450
451 #[must_use]
453 pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
454 self.find_role(role).map(|r| r.hosts.as_slice())
455 }
456
457 #[must_use]
486 pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
487 if !self.enabled {
488 return true;
489 }
490 let Some(role_cfg) = self.find_role(role) else {
491 return false;
492 };
493 for al in &role_cfg.argument_allowlists {
494 if al.tool != tool && !glob_match(&al.tool, tool) {
495 continue;
496 }
497 if al.argument != argument {
498 continue;
499 }
500 if al.allowed.is_empty() {
501 continue;
502 }
503 let Some(tokens) = shlex::split(value) else {
508 return false;
509 };
510 let Some(first_token) = tokens.first() else {
511 return false;
512 };
513 if first_token.is_empty() {
517 return false;
518 }
519 let basename = first_token
523 .rsplit('/')
524 .next()
525 .unwrap_or(first_token.as_str());
526 if !al.allowed.iter().any(|a| a == first_token || a == basename) {
527 return false;
528 }
529 }
530 true
531 }
532
533 #[must_use]
543 pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
544 if !self.enabled {
545 return false;
546 }
547 let Some(role_cfg) = self.find_role(role) else {
548 return false;
549 };
550 role_cfg.argument_allowlists.iter().any(|al| {
551 (al.tool == tool || glob_match(&al.tool, tool))
552 && al.argument == argument
553 && !al.allowed.is_empty()
554 })
555 }
556
557 fn find_role(&self, name: &str) -> Option<&RoleConfig> {
559 self.roles.iter().find(|r| r.name == name)
560 }
561
562 fn host_matches(patterns: &[String], host: &str) -> bool {
564 patterns.iter().any(|p| glob_match(p, host))
565 }
566
567 #[must_use]
576 pub fn redact_arg(&self, value: &str) -> String {
577 redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
578 }
579}
580
581fn process_redaction_salt() -> &'static SecretString {
584 use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
585 static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
586 PROCESS_SALT.get_or_init(|| {
587 let mut bytes = [0u8; 32];
588 rand::fill(&mut bytes);
589 SecretString::from(STANDARD_NO_PAD.encode(bytes))
592 })
593}
594
595fn redact_with_salt(salt: &[u8], value: &str) -> String {
600 use std::fmt::Write as _;
601
602 use sha2::Digest as _;
603
604 type HmacSha256 = Hmac<Sha256>;
605 let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
611 m
612 } else {
613 let digest = Sha256::digest(salt);
614 #[allow(
615 clippy::expect_used,
616 reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
617 )]
618 HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
619 };
620 mac.update(value.as_bytes());
621 let bytes = mac.finalize().into_bytes();
622 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
624 let mut out = String::with_capacity(8);
625 for b in prefix {
626 let _ = write!(out, "{b:02x}");
627 }
628 out
629}
630
631#[allow(
652 clippy::too_many_lines,
653 reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted (see TODO above)"
654)]
655pub(crate) async fn rbac_middleware(
656 policy: Arc<RbacPolicy>,
657 tool_limiter: Option<Arc<ToolRateLimiter>>,
658 req: Request<Body>,
659 next: Next,
660) -> Response {
661 if req.method() != Method::POST {
663 return next.run(req).await;
664 }
665
666 let peer_ip: Option<IpAddr> = req
668 .extensions()
669 .get::<ConnectInfo<std::net::SocketAddr>>()
670 .map(|ci| ci.0.ip())
671 .or_else(|| {
672 req.extensions()
673 .get::<ConnectInfo<TlsConnInfo>>()
674 .map(|ci| ci.0.addr.ip())
675 });
676
677 let identity = req.extensions().get::<AuthIdentity>();
679 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
680 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
681 let raw_token: SecretString = identity
684 .and_then(|id| id.raw_token.clone())
685 .unwrap_or_else(|| SecretString::from(String::new()));
686 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
687
688 if policy.is_enabled() && identity.is_none() {
690 return McpxError::Rbac("no authenticated identity".into()).into_response();
691 }
692
693 let (parts, body) = req.into_parts();
695 let bytes = match body.collect().await {
696 Ok(collected) => collected.to_bytes(),
697 Err(e) => {
698 tracing::error!(error = %e, "failed to read request body");
699 return (
700 StatusCode::INTERNAL_SERVER_ERROR,
701 "failed to read request body",
702 )
703 .into_response();
704 }
705 };
706
707 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
709 let tool_calls = extract_tool_calls(&json);
710 if !tool_calls.is_empty() {
711 for params in tool_calls {
712 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
713 return resp;
714 }
715 if policy.is_enabled()
716 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
717 {
718 return resp;
719 }
720 }
721 }
722 }
723 let req = Request::from_parts(parts, Body::from(bytes));
727
728 if role.is_empty() {
730 next.run(req).await
731 } else {
732 CURRENT_ROLE
733 .scope(
734 role,
735 CURRENT_IDENTITY.scope(
736 identity_name,
737 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
738 ),
739 )
740 .await
741 }
742}
743
744fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
750 match value {
751 serde_json::Value::Object(map) => map
752 .get("method")
753 .and_then(serde_json::Value::as_str)
754 .filter(|method| *method == "tools/call")
755 .and_then(|_| map.get("params"))
756 .into_iter()
757 .collect(),
758 serde_json::Value::Array(items) => items
759 .iter()
760 .filter_map(|item| match item {
761 serde_json::Value::Object(map) => map
762 .get("method")
763 .and_then(serde_json::Value::as_str)
764 .filter(|method| *method == "tools/call")
765 .and_then(|_| map.get("params")),
766 serde_json::Value::Null
767 | serde_json::Value::Bool(_)
768 | serde_json::Value::Number(_)
769 | serde_json::Value::String(_)
770 | serde_json::Value::Array(_) => None,
771 })
772 .collect(),
773 serde_json::Value::Null
774 | serde_json::Value::Bool(_)
775 | serde_json::Value::Number(_)
776 | serde_json::Value::String(_) => Vec::new(),
777 }
778}
779
780fn enforce_rate_limit(
783 tool_limiter: Option<&ToolRateLimiter>,
784 peer_ip: Option<IpAddr>,
785) -> Option<Response> {
786 let limiter = tool_limiter?;
787 let ip = peer_ip?;
788 if limiter.check_key(&ip).is_err() {
789 tracing::warn!(%ip, "tool invocation rate limited");
790 return Some(McpxError::RateLimited("too many tool invocations".into()).into_response());
791 }
792 None
793}
794
795fn enforce_tool_policy(
804 policy: &RbacPolicy,
805 identity_name: &str,
806 role: &str,
807 params: &serde_json::Value,
808) -> Option<Response> {
809 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
810 let host = params
811 .get("arguments")
812 .and_then(|a| a.get("host"))
813 .and_then(|h| h.as_str());
814
815 let decision = if let Some(host) = host {
816 policy.check(role, tool_name, host)
817 } else {
818 policy.check_operation(role, tool_name)
819 };
820 if decision == RbacDecision::Deny {
821 tracing::warn!(
822 user = %identity_name,
823 role = %role,
824 tool = tool_name,
825 host = host.unwrap_or("-"),
826 "RBAC denied"
827 );
828 return Some(
829 McpxError::Rbac(format!("{tool_name} denied for role '{role}'")).into_response(),
830 );
831 }
832
833 let args = params.get("arguments").and_then(|a| a.as_object())?;
834 for (arg_key, arg_val) in args {
835 if let Some(resp) = check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
836 {
837 return Some(resp);
838 }
839 }
840 None
841}
842
843fn check_argument(
844 policy: &RbacPolicy,
845 identity_name: &str,
846 role: &str,
847 tool_name: &str,
848 arg_key: &str,
849 arg_val: &serde_json::Value,
850) -> Option<Response> {
851 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
852 return None;
853 }
854 let Some(val_str) = arg_val.as_str() else {
855 tracing::warn!(
861 user = %identity_name,
862 role = %role,
863 tool = tool_name,
864 argument = arg_key,
865 value_type = json_value_type(arg_val),
866 "non-string argument rejected by allowlist"
867 );
868 return Some(
869 McpxError::Rbac(format!(
870 "argument '{arg_key}' must be a string for tool '{tool_name}'"
871 ))
872 .into_response(),
873 );
874 };
875 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
876 return None;
877 }
878 tracing::warn!(
883 user = %identity_name,
884 role = %role,
885 tool = tool_name,
886 argument = arg_key,
887 arg_hmac = %policy.redact_arg(val_str),
888 "argument not in allowlist"
889 );
890 Some(
891 McpxError::Rbac(format!(
892 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
893 ))
894 .into_response(),
895 )
896}
897
898fn json_value_type(v: &serde_json::Value) -> &'static str {
899 match v {
900 serde_json::Value::Null => "null",
901 serde_json::Value::Bool(_) => "bool",
902 serde_json::Value::Number(_) => "number",
903 serde_json::Value::String(_) => "string",
904 serde_json::Value::Array(_) => "array",
905 serde_json::Value::Object(_) => "object",
906 }
907}
908
909fn glob_match(pattern: &str, text: &str) -> bool {
914 let parts: Vec<&str> = pattern.split('*').collect();
915 if parts.len() == 1 {
916 return pattern == text;
918 }
919
920 let mut pos = 0;
921
922 if let Some(&first) = parts.first()
924 && !first.is_empty()
925 {
926 if !text.starts_with(first) {
927 return false;
928 }
929 pos = first.len();
930 }
931
932 if let Some(&last) = parts.last()
934 && !last.is_empty()
935 {
936 if !text[pos..].ends_with(last) {
937 return false;
938 }
939 let end = text.len() - last.len();
941 if pos > end {
942 return false;
943 }
944 let middle = &text[pos..end];
946 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
947 return match_middle(middle, middle_parts);
948 }
949
950 let middle = &text[pos..];
952 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
953 match_middle(middle, middle_parts)
954}
955
956fn match_middle(mut text: &str, parts: &[&str]) -> bool {
958 for part in parts {
959 if part.is_empty() {
960 continue;
961 }
962 if let Some(idx) = text.find(part) {
963 text = &text[idx + part.len()..];
964 } else {
965 return false;
966 }
967 }
968 true
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974
975 fn test_policy() -> RbacPolicy {
976 RbacPolicy::new(&RbacConfig {
977 enabled: true,
978 roles: vec![
979 RoleConfig {
980 name: "viewer".into(),
981 description: Some("Read-only".into()),
982 allow: vec![
983 "list_hosts".into(),
984 "resource_list".into(),
985 "resource_inspect".into(),
986 "resource_logs".into(),
987 "system_info".into(),
988 ],
989 deny: vec![],
990 hosts: vec!["*".into()],
991 argument_allowlists: vec![],
992 },
993 RoleConfig {
994 name: "deploy".into(),
995 description: Some("Lifecycle management".into()),
996 allow: vec![
997 "list_hosts".into(),
998 "resource_list".into(),
999 "resource_run".into(),
1000 "resource_start".into(),
1001 "resource_stop".into(),
1002 "resource_restart".into(),
1003 "resource_logs".into(),
1004 "image_pull".into(),
1005 ],
1006 deny: vec!["resource_delete".into(), "resource_exec".into()],
1007 hosts: vec!["web-*".into(), "api-*".into()],
1008 argument_allowlists: vec![],
1009 },
1010 RoleConfig {
1011 name: "ops".into(),
1012 description: Some("Full access".into()),
1013 allow: vec!["*".into()],
1014 deny: vec![],
1015 hosts: vec!["*".into()],
1016 argument_allowlists: vec![],
1017 },
1018 RoleConfig {
1019 name: "restricted-exec".into(),
1020 description: Some("Exec with argument allowlist".into()),
1021 allow: vec!["resource_exec".into()],
1022 deny: vec![],
1023 hosts: vec!["dev-*".into()],
1024 argument_allowlists: vec![ArgumentAllowlist {
1025 tool: "resource_exec".into(),
1026 argument: "cmd".into(),
1027 allowed: vec![
1028 "sh".into(),
1029 "bash".into(),
1030 "cat".into(),
1031 "ls".into(),
1032 "ps".into(),
1033 ],
1034 }],
1035 },
1036 ],
1037 redaction_salt: None,
1038 })
1039 }
1040
1041 #[test]
1044 fn glob_exact_match() {
1045 assert!(glob_match("web-prod-1", "web-prod-1"));
1046 assert!(!glob_match("web-prod-1", "web-prod-2"));
1047 }
1048
1049 #[test]
1050 fn glob_star_suffix() {
1051 assert!(glob_match("web-*", "web-prod-1"));
1052 assert!(glob_match("web-*", "web-staging"));
1053 assert!(!glob_match("web-*", "api-prod"));
1054 }
1055
1056 #[test]
1057 fn glob_star_prefix() {
1058 assert!(glob_match("*-prod", "web-prod"));
1059 assert!(glob_match("*-prod", "api-prod"));
1060 assert!(!glob_match("*-prod", "web-staging"));
1061 }
1062
1063 #[test]
1064 fn glob_star_middle() {
1065 assert!(glob_match("web-*-prod", "web-us-prod"));
1066 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1067 assert!(!glob_match("web-*-prod", "web-staging"));
1068 }
1069
1070 #[test]
1071 fn glob_star_only() {
1072 assert!(glob_match("*", "anything"));
1073 assert!(glob_match("*", ""));
1074 }
1075
1076 #[test]
1077 fn glob_multiple_stars() {
1078 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1079 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1080 }
1081
1082 #[test]
1094 fn glob_prefix_and_suffix_meet_exactly() {
1095 assert!(glob_match("ab*cd", "abcd"));
1098 }
1099
1100 #[test]
1105 fn glob_middle_segment_required_with_suffix() {
1106 assert!(!glob_match("a*b*c", "axyc"));
1111 }
1112
1113 #[test]
1119 fn glob_match_middle_advances_past_matched_part() {
1120 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1125 }
1126
1127 #[test]
1132 fn glob_match_middle_uses_addition_not_multiplication() {
1133 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1137 }
1138
1139 #[test]
1148 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1149 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1157 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1158 "run-*",
1159 "cmd",
1160 vec!["ls".into()],
1161 )]);
1162 let mut config = RbacConfig::with_roles(vec![role]);
1163 config.enabled = true;
1164 let policy = RbacPolicy::new(&config);
1165 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1166 }
1167
1168 #[test]
1171 fn disabled_policy_allows_everything() {
1172 let policy = RbacPolicy::new(&RbacConfig {
1173 enabled: false,
1174 roles: vec![],
1175 redaction_salt: None,
1176 });
1177 assert_eq!(
1178 policy.check("nonexistent", "resource_delete", "any-host"),
1179 RbacDecision::Allow
1180 );
1181 }
1182
1183 #[test]
1184 fn unknown_role_denied() {
1185 let policy = test_policy();
1186 assert_eq!(
1187 policy.check("unknown", "resource_list", "web-prod-1"),
1188 RbacDecision::Deny
1189 );
1190 }
1191
1192 #[test]
1193 fn viewer_allowed_read_ops() {
1194 let policy = test_policy();
1195 assert_eq!(
1196 policy.check("viewer", "resource_list", "web-prod-1"),
1197 RbacDecision::Allow
1198 );
1199 assert_eq!(
1200 policy.check("viewer", "system_info", "db-host"),
1201 RbacDecision::Allow
1202 );
1203 }
1204
1205 #[test]
1206 fn viewer_denied_write_ops() {
1207 let policy = test_policy();
1208 assert_eq!(
1209 policy.check("viewer", "resource_run", "web-prod-1"),
1210 RbacDecision::Deny
1211 );
1212 assert_eq!(
1213 policy.check("viewer", "resource_delete", "web-prod-1"),
1214 RbacDecision::Deny
1215 );
1216 }
1217
1218 #[test]
1219 fn deploy_allowed_on_matching_hosts() {
1220 let policy = test_policy();
1221 assert_eq!(
1222 policy.check("deploy", "resource_run", "web-prod-1"),
1223 RbacDecision::Allow
1224 );
1225 assert_eq!(
1226 policy.check("deploy", "resource_start", "api-staging"),
1227 RbacDecision::Allow
1228 );
1229 }
1230
1231 #[test]
1232 fn deploy_denied_on_non_matching_host() {
1233 let policy = test_policy();
1234 assert_eq!(
1235 policy.check("deploy", "resource_run", "db-prod-1"),
1236 RbacDecision::Deny
1237 );
1238 }
1239
1240 #[test]
1241 fn deny_overrides_allow() {
1242 let policy = test_policy();
1243 assert_eq!(
1244 policy.check("deploy", "resource_delete", "web-prod-1"),
1245 RbacDecision::Deny
1246 );
1247 assert_eq!(
1248 policy.check("deploy", "resource_exec", "web-prod-1"),
1249 RbacDecision::Deny
1250 );
1251 }
1252
1253 #[test]
1254 fn ops_wildcard_allows_everything() {
1255 let policy = test_policy();
1256 assert_eq!(
1257 policy.check("ops", "resource_delete", "any-host"),
1258 RbacDecision::Allow
1259 );
1260 assert_eq!(
1261 policy.check("ops", "secret_create", "db-host"),
1262 RbacDecision::Allow
1263 );
1264 }
1265
1266 #[test]
1269 fn host_visible_respects_globs() {
1270 let policy = test_policy();
1271 assert!(policy.host_visible("deploy", "web-prod-1"));
1272 assert!(policy.host_visible("deploy", "api-staging"));
1273 assert!(!policy.host_visible("deploy", "db-prod-1"));
1274 assert!(policy.host_visible("ops", "anything"));
1275 assert!(policy.host_visible("viewer", "anything"));
1276 }
1277
1278 #[test]
1279 fn host_visible_unknown_role() {
1280 let policy = test_policy();
1281 assert!(!policy.host_visible("unknown", "web-prod-1"));
1282 }
1283
1284 #[test]
1287 fn argument_allowed_no_allowlist() {
1288 let policy = test_policy();
1289 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1291 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1292 }
1293
1294 #[test]
1295 fn argument_allowed_with_allowlist() {
1296 let policy = test_policy();
1297 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1298 assert!(policy.argument_allowed(
1299 "restricted-exec",
1300 "resource_exec",
1301 "cmd",
1302 "bash -c 'echo hi'"
1303 ));
1304 assert!(policy.argument_allowed(
1305 "restricted-exec",
1306 "resource_exec",
1307 "cmd",
1308 "cat /etc/hosts"
1309 ));
1310 assert!(policy.argument_allowed(
1311 "restricted-exec",
1312 "resource_exec",
1313 "cmd",
1314 "/usr/bin/ls -la"
1315 ));
1316 }
1317
1318 #[test]
1319 fn argument_denied_not_in_allowlist() {
1320 let policy = test_policy();
1321 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1322 assert!(!policy.argument_allowed(
1323 "restricted-exec",
1324 "resource_exec",
1325 "cmd",
1326 "python3 exploit.py"
1327 ));
1328 assert!(!policy.argument_allowed(
1329 "restricted-exec",
1330 "resource_exec",
1331 "cmd",
1332 "/usr/bin/curl evil.com"
1333 ));
1334 }
1335
1336 #[test]
1337 fn argument_denied_unknown_role() {
1338 let policy = test_policy();
1339 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1340 }
1341
1342 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1351 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1352 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1353 let mut config = RbacConfig::with_roles(vec![role]);
1354 config.enabled = true;
1355 RbacPolicy::new(&config)
1356 }
1357
1358 #[test]
1359 fn argument_allowed_matches_quoted_path_with_spaces() {
1360 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1361 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1362 }
1363
1364 #[test]
1365 fn argument_allowed_matches_basename_of_quoted_path() {
1366 let policy = shlex_policy(vec!["my tool".into()]);
1367 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1368 }
1369
1370 #[test]
1371 fn argument_allowed_fails_closed_on_unbalanced_quote() {
1372 let policy = shlex_policy(vec!["unbalanced".into()]);
1373 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1374 }
1375
1376 #[test]
1377 fn argument_allowed_fails_closed_on_empty_string() {
1378 let policy = shlex_policy(vec![String::new()]);
1379 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1380 }
1381
1382 #[test]
1383 fn argument_allowed_handles_single_quoted_executable() {
1384 let policy = shlex_policy(vec!["/bin/sh".into()]);
1385 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1386 }
1387
1388 #[test]
1389 fn argument_allowed_handles_tab_separator() {
1390 let policy = shlex_policy(vec!["ls".into()]);
1391 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1392 }
1393
1394 #[test]
1395 fn argument_allowed_plain_token_unchanged() {
1396 let policy = shlex_policy(vec!["ls".into()]);
1397 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1398 }
1399
1400 #[test]
1406 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1407 let policy = shlex_policy(vec![String::new()]);
1411 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1412 }
1413
1414 #[test]
1415 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1416 let policy = shlex_policy(vec!["'bash'".into()]);
1422 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1423 }
1424
1425 #[test]
1426 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1427 let policy = shlex_policy(vec![r"foo\bar".into()]);
1432 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1433 }
1434
1435 #[test]
1436 fn argument_allowed_windows_path_no_longer_matches() {
1437 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1442 assert!(!policy.argument_allowed(
1443 "viewer",
1444 "run",
1445 "cmd",
1446 r"C:\Windows\System32\cmd.exe /c dir"
1447 ));
1448 }
1449
1450 #[test]
1453 fn host_patterns_returns_globs() {
1454 let policy = test_policy();
1455 assert_eq!(
1456 policy.host_patterns("deploy"),
1457 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1458 );
1459 assert_eq!(
1460 policy.host_patterns("ops"),
1461 Some(vec!["*".to_owned()].as_slice())
1462 );
1463 assert!(policy.host_patterns("nonexistent").is_none());
1464 }
1465
1466 #[test]
1469 fn check_operation_allows_without_host() {
1470 let policy = test_policy();
1471 assert_eq!(
1472 policy.check_operation("deploy", "resource_run"),
1473 RbacDecision::Allow
1474 );
1475 assert_eq!(
1477 policy.check("deploy", "resource_run", "db-prod-1"),
1478 RbacDecision::Deny
1479 );
1480 }
1481
1482 #[test]
1483 fn check_operation_deny_overrides() {
1484 let policy = test_policy();
1485 assert_eq!(
1486 policy.check_operation("deploy", "resource_delete"),
1487 RbacDecision::Deny
1488 );
1489 }
1490
1491 #[test]
1492 fn check_operation_unknown_role() {
1493 let policy = test_policy();
1494 assert_eq!(
1495 policy.check_operation("unknown", "resource_list"),
1496 RbacDecision::Deny
1497 );
1498 }
1499
1500 #[test]
1501 fn check_operation_disabled() {
1502 let policy = RbacPolicy::new(&RbacConfig {
1503 enabled: false,
1504 roles: vec![],
1505 redaction_salt: None,
1506 });
1507 assert_eq!(
1508 policy.check_operation("nonexistent", "anything"),
1509 RbacDecision::Allow
1510 );
1511 }
1512
1513 #[test]
1516 fn current_role_returns_none_outside_scope() {
1517 assert!(current_role().is_none());
1518 }
1519
1520 #[test]
1521 fn current_identity_returns_none_outside_scope() {
1522 assert!(current_identity().is_none());
1523 }
1524
1525 use axum::{
1528 body::Body,
1529 http::{Method, Request, StatusCode},
1530 };
1531 use tower::ServiceExt as _;
1532
1533 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1534 serde_json::json!({
1535 "jsonrpc": "2.0",
1536 "id": 1,
1537 "method": "tools/call",
1538 "params": {
1539 "name": tool,
1540 "arguments": args
1541 }
1542 })
1543 .to_string()
1544 }
1545
1546 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1547 axum::Router::new()
1548 .route("/mcp", axum::routing::post(|| async { "ok" }))
1549 .layer(axum::middleware::from_fn(move |req, next| {
1550 let p = Arc::clone(&policy);
1551 rbac_middleware(p, None, req, next)
1552 }))
1553 }
1554
1555 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1556 axum::Router::new()
1557 .route("/mcp", axum::routing::post(|| async { "ok" }))
1558 .layer(axum::middleware::from_fn(
1559 move |mut req: Request<Body>, next: Next| {
1560 let p = Arc::clone(&policy);
1561 let id = identity.clone();
1562 async move {
1563 req.extensions_mut().insert(id);
1564 rbac_middleware(p, None, req, next).await
1565 }
1566 },
1567 ))
1568 }
1569
1570 #[tokio::test]
1571 async fn middleware_passes_non_post() {
1572 let policy = Arc::new(test_policy());
1573 let app = rbac_router(policy);
1574 let req = Request::builder()
1576 .method(Method::GET)
1577 .uri("/mcp")
1578 .body(Body::empty())
1579 .unwrap();
1580 let resp = app.oneshot(req).await.unwrap();
1583 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1584 }
1585
1586 #[tokio::test]
1587 async fn middleware_denies_without_identity() {
1588 let policy = Arc::new(test_policy());
1589 let app = rbac_router(policy);
1590 let body = tool_call_body("resource_list", &serde_json::json!({}));
1591 let req = Request::builder()
1592 .method(Method::POST)
1593 .uri("/mcp")
1594 .header("content-type", "application/json")
1595 .body(Body::from(body))
1596 .unwrap();
1597 let resp = app.oneshot(req).await.unwrap();
1598 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1599 }
1600
1601 #[tokio::test]
1602 async fn middleware_allows_permitted_tool() {
1603 let policy = Arc::new(test_policy());
1604 let id = AuthIdentity {
1605 method: crate::auth::AuthMethod::BearerToken,
1606 name: "alice".into(),
1607 role: "viewer".into(),
1608 raw_token: None,
1609 sub: None,
1610 };
1611 let app = rbac_router_with_identity(policy, id);
1612 let body = tool_call_body("resource_list", &serde_json::json!({}));
1613 let req = Request::builder()
1614 .method(Method::POST)
1615 .uri("/mcp")
1616 .header("content-type", "application/json")
1617 .body(Body::from(body))
1618 .unwrap();
1619 let resp = app.oneshot(req).await.unwrap();
1620 assert_eq!(resp.status(), StatusCode::OK);
1621 }
1622
1623 #[tokio::test]
1624 async fn middleware_denies_unpermitted_tool() {
1625 let policy = Arc::new(test_policy());
1626 let id = AuthIdentity {
1627 method: crate::auth::AuthMethod::BearerToken,
1628 name: "alice".into(),
1629 role: "viewer".into(),
1630 raw_token: None,
1631 sub: None,
1632 };
1633 let app = rbac_router_with_identity(policy, id);
1634 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1635 let req = Request::builder()
1636 .method(Method::POST)
1637 .uri("/mcp")
1638 .header("content-type", "application/json")
1639 .body(Body::from(body))
1640 .unwrap();
1641 let resp = app.oneshot(req).await.unwrap();
1642 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1643 }
1644
1645 #[tokio::test]
1646 async fn middleware_passes_non_tool_call_post() {
1647 let policy = Arc::new(test_policy());
1648 let id = AuthIdentity {
1649 method: crate::auth::AuthMethod::BearerToken,
1650 name: "alice".into(),
1651 role: "viewer".into(),
1652 raw_token: None,
1653 sub: None,
1654 };
1655 let app = rbac_router_with_identity(policy, id);
1656 let body = serde_json::json!({
1658 "jsonrpc": "2.0",
1659 "id": 1,
1660 "method": "resources/list"
1661 })
1662 .to_string();
1663 let req = Request::builder()
1664 .method(Method::POST)
1665 .uri("/mcp")
1666 .header("content-type", "application/json")
1667 .body(Body::from(body))
1668 .unwrap();
1669 let resp = app.oneshot(req).await.unwrap();
1670 assert_eq!(resp.status(), StatusCode::OK);
1671 }
1672
1673 #[tokio::test]
1674 async fn middleware_enforces_argument_allowlist() {
1675 let policy = Arc::new(test_policy());
1676 let id = AuthIdentity {
1677 method: crate::auth::AuthMethod::BearerToken,
1678 name: "dev".into(),
1679 role: "restricted-exec".into(),
1680 raw_token: None,
1681 sub: None,
1682 };
1683 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1685 let body = tool_call_body(
1686 "resource_exec",
1687 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1688 );
1689 let req = Request::builder()
1690 .method(Method::POST)
1691 .uri("/mcp")
1692 .body(Body::from(body))
1693 .unwrap();
1694 let resp = app.oneshot(req).await.unwrap();
1695 assert_eq!(resp.status(), StatusCode::OK);
1696
1697 let app = rbac_router_with_identity(policy, id);
1699 let body = tool_call_body(
1700 "resource_exec",
1701 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1702 );
1703 let req = Request::builder()
1704 .method(Method::POST)
1705 .uri("/mcp")
1706 .body(Body::from(body))
1707 .unwrap();
1708 let resp = app.oneshot(req).await.unwrap();
1709 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1710 }
1711
1712 #[tokio::test]
1713 async fn middleware_disabled_policy_passes_everything() {
1714 let policy = Arc::new(RbacPolicy::disabled());
1715 let app = rbac_router(policy);
1716 let body = tool_call_body("anything", &serde_json::json!({}));
1718 let req = Request::builder()
1719 .method(Method::POST)
1720 .uri("/mcp")
1721 .body(Body::from(body))
1722 .unwrap();
1723 let resp = app.oneshot(req).await.unwrap();
1724 assert_eq!(resp.status(), StatusCode::OK);
1725 }
1726
1727 #[tokio::test]
1728 async fn middleware_batch_all_allowed_passes() {
1729 let policy = Arc::new(test_policy());
1730 let id = AuthIdentity {
1731 method: crate::auth::AuthMethod::BearerToken,
1732 name: "alice".into(),
1733 role: "viewer".into(),
1734 raw_token: None,
1735 sub: None,
1736 };
1737 let app = rbac_router_with_identity(policy, id);
1738 let body = serde_json::json!([
1739 {
1740 "jsonrpc": "2.0",
1741 "id": 1,
1742 "method": "tools/call",
1743 "params": { "name": "resource_list", "arguments": {} }
1744 },
1745 {
1746 "jsonrpc": "2.0",
1747 "id": 2,
1748 "method": "tools/call",
1749 "params": { "name": "system_info", "arguments": {} }
1750 }
1751 ])
1752 .to_string();
1753 let req = Request::builder()
1754 .method(Method::POST)
1755 .uri("/mcp")
1756 .header("content-type", "application/json")
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_with_denied_call_rejects_entire_batch() {
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": "resource_delete", "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::FORBIDDEN);
1797 }
1798
1799 #[tokio::test]
1800 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1801 let policy = Arc::new(test_policy());
1802 let id = AuthIdentity {
1803 method: crate::auth::AuthMethod::BearerToken,
1804 name: "dev".into(),
1805 role: "restricted-exec".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": {
1816 "name": "resource_exec",
1817 "arguments": { "cmd": "ls -la", "host": "dev-1" }
1818 }
1819 },
1820 {
1821 "jsonrpc": "2.0",
1822 "id": 2,
1823 "method": "tools/call",
1824 "params": {
1825 "name": "resource_exec",
1826 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1827 }
1828 }
1829 ])
1830 .to_string();
1831 let req = Request::builder()
1832 .method(Method::POST)
1833 .uri("/mcp")
1834 .header("content-type", "application/json")
1835 .body(Body::from(body))
1836 .unwrap();
1837 let resp = app.oneshot(req).await.unwrap();
1838 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1839 }
1840
1841 #[test]
1844 fn redact_with_salt_is_deterministic_per_salt() {
1845 let salt = b"unit-test-salt";
1846 let a = redact_with_salt(salt, "rm -rf /");
1847 let b = redact_with_salt(salt, "rm -rf /");
1848 assert_eq!(a, b, "same input + salt must yield identical hash");
1849 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1850 assert!(
1851 a.chars().all(|c| c.is_ascii_hexdigit()),
1852 "redacted hash must be lowercase hex: {a}"
1853 );
1854 }
1855
1856 #[test]
1857 fn redact_with_salt_differs_across_salts() {
1858 let v = "the-same-value";
1859 let h1 = redact_with_salt(b"salt-one", v);
1860 let h2 = redact_with_salt(b"salt-two", v);
1861 assert_ne!(
1862 h1, h2,
1863 "different salts must produce different hashes for the same value"
1864 );
1865 }
1866
1867 #[test]
1868 fn redact_with_salt_distinguishes_values() {
1869 let salt = b"k";
1870 let h1 = redact_with_salt(salt, "alpha");
1871 let h2 = redact_with_salt(salt, "beta");
1872 assert_ne!(h1, h2, "different values must produce different hashes");
1874 }
1875
1876 #[test]
1877 fn policy_with_configured_salt_redacts_consistently() {
1878 let cfg = RbacConfig {
1879 enabled: true,
1880 roles: vec![],
1881 redaction_salt: Some(SecretString::from("my-stable-salt")),
1882 };
1883 let p1 = RbacPolicy::new(&cfg);
1884 let p2 = RbacPolicy::new(&cfg);
1885 assert_eq!(
1886 p1.redact_arg("payload"),
1887 p2.redact_arg("payload"),
1888 "policies built from the same configured salt must agree"
1889 );
1890 }
1891
1892 #[test]
1893 fn policy_without_configured_salt_uses_process_salt() {
1894 let cfg = RbacConfig {
1895 enabled: true,
1896 roles: vec![],
1897 redaction_salt: None,
1898 };
1899 let p1 = RbacPolicy::new(&cfg);
1900 let p2 = RbacPolicy::new(&cfg);
1901 assert_eq!(
1903 p1.redact_arg("payload"),
1904 p2.redact_arg("payload"),
1905 "process-wide salt must be consistent within one process"
1906 );
1907 }
1908
1909 #[test]
1910 fn redact_arg_is_fast_enough() {
1911 let salt = b"perf-sanity-salt-32-bytes-padded";
1915 let value = "x".repeat(256);
1916 let start = std::time::Instant::now();
1917 let _ = redact_with_salt(salt, &value);
1918 let elapsed = start.elapsed();
1919 assert!(
1920 elapsed < Duration::from_millis(5),
1921 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
1922 );
1923 }
1924
1925 #[tokio::test]
1937 async fn deny_path_uses_explicit_identity_not_task_local() {
1938 let policy = Arc::new(test_policy());
1939 let id = AuthIdentity {
1940 method: crate::auth::AuthMethod::BearerToken,
1941 name: "alice-the-auditor".into(),
1942 role: "viewer".into(),
1943 raw_token: None,
1944 sub: None,
1945 };
1946 let app = rbac_router_with_identity(policy, id);
1947 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1949 let req = Request::builder()
1950 .method(Method::POST)
1951 .uri("/mcp")
1952 .header("content-type", "application/json")
1953 .body(Body::from(body))
1954 .unwrap();
1955 let resp = app.oneshot(req).await.unwrap();
1956 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1957 }
1958
1959 fn restricted_exec_identity() -> AuthIdentity {
1962 AuthIdentity {
1963 method: crate::auth::AuthMethod::BearerToken,
1964 name: "carol".into(),
1965 role: "restricted-exec".into(),
1966 raw_token: None,
1967 sub: None,
1968 }
1969 }
1970
1971 #[test]
1972 fn has_argument_allowlist_matches_configured_tool_argument() {
1973 let policy = test_policy();
1974 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
1975 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
1976 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
1977 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
1978 }
1979
1980 #[tokio::test]
1981 async fn array_arg_with_matching_allowlist_is_denied() {
1982 let policy = Arc::new(test_policy());
1983 let app = rbac_router_with_identity(policy, restricted_exec_identity());
1984 let body = tool_call_body(
1985 "resource_exec",
1986 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
1987 );
1988 let req = Request::builder()
1989 .method(Method::POST)
1990 .uri("/mcp")
1991 .header("content-type", "application/json")
1992 .body(Body::from(body))
1993 .unwrap();
1994 let resp = app.oneshot(req).await.unwrap();
1995 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1996 }
1997
1998 #[tokio::test]
1999 async fn object_arg_with_matching_allowlist_is_denied() {
2000 let policy = Arc::new(test_policy());
2001 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2002 let body = tool_call_body(
2003 "resource_exec",
2004 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2005 );
2006 let req = Request::builder()
2007 .method(Method::POST)
2008 .uri("/mcp")
2009 .header("content-type", "application/json")
2010 .body(Body::from(body))
2011 .unwrap();
2012 let resp = app.oneshot(req).await.unwrap();
2013 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2014 }
2015
2016 #[tokio::test]
2017 async fn number_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": 42 }),
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 bool_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": true }),
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 null_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": null }),
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 non_string_arg_without_allowlist_is_passthrough() {
2072 let policy = Arc::new(test_policy());
2076 let id = AuthIdentity {
2077 method: crate::auth::AuthMethod::BearerToken,
2078 name: "olivia".into(),
2079 role: "ops".into(),
2080 raw_token: None,
2081 sub: None,
2082 };
2083 let app = rbac_router_with_identity(policy, id);
2084 let body = tool_call_body(
2085 "resource_exec",
2086 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2087 );
2088 let req = Request::builder()
2089 .method(Method::POST)
2090 .uri("/mcp")
2091 .header("content-type", "application/json")
2092 .body(Body::from(body))
2093 .unwrap();
2094 let resp = app.oneshot(req).await.unwrap();
2095 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2096 }
2097
2098 #[tokio::test]
2099 async fn string_arg_in_allowlist_still_passes() {
2100 let policy = Arc::new(test_policy());
2101 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2102 let body = tool_call_body(
2103 "resource_exec",
2104 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2105 );
2106 let req = Request::builder()
2107 .method(Method::POST)
2108 .uri("/mcp")
2109 .header("content-type", "application/json")
2110 .body(Body::from(body))
2111 .unwrap();
2112 let resp = app.oneshot(req).await.unwrap();
2113 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2114 }
2115}