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, 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(clippy::expect_used)] HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
616 };
617 mac.update(value.as_bytes());
618 let bytes = mac.finalize().into_bytes();
619 let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
621 let mut out = String::with_capacity(8);
622 for b in prefix {
623 let _ = write!(out, "{b:02x}");
624 }
625 out
626}
627
628#[allow(clippy::too_many_lines)]
649pub(crate) async fn rbac_middleware(
650 policy: Arc<RbacPolicy>,
651 tool_limiter: Option<Arc<ToolRateLimiter>>,
652 req: Request<Body>,
653 next: Next,
654) -> Response {
655 if req.method() != Method::POST {
657 return next.run(req).await;
658 }
659
660 let peer_ip: Option<IpAddr> = req
662 .extensions()
663 .get::<ConnectInfo<std::net::SocketAddr>>()
664 .map(|ci| ci.0.ip())
665 .or_else(|| {
666 req.extensions()
667 .get::<ConnectInfo<TlsConnInfo>>()
668 .map(|ci| ci.0.addr.ip())
669 });
670
671 let identity = req.extensions().get::<AuthIdentity>();
673 let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
674 let role = identity.map(|id| id.role.clone()).unwrap_or_default();
675 let raw_token: SecretString = identity
678 .and_then(|id| id.raw_token.clone())
679 .unwrap_or_else(|| SecretString::from(String::new()));
680 let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
681
682 if policy.is_enabled() && identity.is_none() {
684 return McpxError::Rbac("no authenticated identity".into()).into_response();
685 }
686
687 let (parts, body) = req.into_parts();
689 let bytes = match body.collect().await {
690 Ok(collected) => collected.to_bytes(),
691 Err(e) => {
692 tracing::error!(error = %e, "failed to read request body");
693 return (
694 StatusCode::INTERNAL_SERVER_ERROR,
695 "failed to read request body",
696 )
697 .into_response();
698 }
699 };
700
701 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
703 let tool_calls = extract_tool_calls(&json);
704 if !tool_calls.is_empty() {
705 for params in tool_calls {
706 if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
707 return resp;
708 }
709 if policy.is_enabled()
710 && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
711 {
712 return resp;
713 }
714 }
715 }
716 }
717 let req = Request::from_parts(parts, Body::from(bytes));
721
722 if role.is_empty() {
724 next.run(req).await
725 } else {
726 CURRENT_ROLE
727 .scope(
728 role,
729 CURRENT_IDENTITY.scope(
730 identity_name,
731 CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
732 ),
733 )
734 .await
735 }
736}
737
738fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
744 match value {
745 serde_json::Value::Object(map) => map
746 .get("method")
747 .and_then(serde_json::Value::as_str)
748 .filter(|method| *method == "tools/call")
749 .and_then(|_| map.get("params"))
750 .into_iter()
751 .collect(),
752 serde_json::Value::Array(items) => items
753 .iter()
754 .filter_map(|item| match item {
755 serde_json::Value::Object(map) => map
756 .get("method")
757 .and_then(serde_json::Value::as_str)
758 .filter(|method| *method == "tools/call")
759 .and_then(|_| map.get("params")),
760 serde_json::Value::Null
761 | serde_json::Value::Bool(_)
762 | serde_json::Value::Number(_)
763 | serde_json::Value::String(_)
764 | serde_json::Value::Array(_) => None,
765 })
766 .collect(),
767 serde_json::Value::Null
768 | serde_json::Value::Bool(_)
769 | serde_json::Value::Number(_)
770 | serde_json::Value::String(_) => Vec::new(),
771 }
772}
773
774fn enforce_rate_limit(
777 tool_limiter: Option<&ToolRateLimiter>,
778 peer_ip: Option<IpAddr>,
779) -> Option<Response> {
780 let limiter = tool_limiter?;
781 let ip = peer_ip?;
782 if limiter.check_key(&ip).is_err() {
783 tracing::warn!(%ip, "tool invocation rate limited");
784 return Some(McpxError::RateLimited("too many tool invocations".into()).into_response());
785 }
786 None
787}
788
789fn enforce_tool_policy(
798 policy: &RbacPolicy,
799 identity_name: &str,
800 role: &str,
801 params: &serde_json::Value,
802) -> Option<Response> {
803 let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
804 let host = params
805 .get("arguments")
806 .and_then(|a| a.get("host"))
807 .and_then(|h| h.as_str());
808
809 let decision = if let Some(host) = host {
810 policy.check(role, tool_name, host)
811 } else {
812 policy.check_operation(role, tool_name)
813 };
814 if decision == RbacDecision::Deny {
815 tracing::warn!(
816 user = %identity_name,
817 role = %role,
818 tool = tool_name,
819 host = host.unwrap_or("-"),
820 "RBAC denied"
821 );
822 return Some(
823 McpxError::Rbac(format!("{tool_name} denied for role '{role}'")).into_response(),
824 );
825 }
826
827 let args = params.get("arguments").and_then(|a| a.as_object())?;
828 for (arg_key, arg_val) in args {
829 if let Some(resp) = check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
830 {
831 return Some(resp);
832 }
833 }
834 None
835}
836
837fn check_argument(
838 policy: &RbacPolicy,
839 identity_name: &str,
840 role: &str,
841 tool_name: &str,
842 arg_key: &str,
843 arg_val: &serde_json::Value,
844) -> Option<Response> {
845 if !policy.has_argument_allowlist(role, tool_name, arg_key) {
846 return None;
847 }
848 let Some(val_str) = arg_val.as_str() else {
849 tracing::warn!(
855 user = %identity_name,
856 role = %role,
857 tool = tool_name,
858 argument = arg_key,
859 value_type = json_value_type(arg_val),
860 "non-string argument rejected by allowlist"
861 );
862 return Some(
863 McpxError::Rbac(format!(
864 "argument '{arg_key}' must be a string for tool '{tool_name}'"
865 ))
866 .into_response(),
867 );
868 };
869 if policy.argument_allowed(role, tool_name, arg_key, val_str) {
870 return None;
871 }
872 tracing::warn!(
877 user = %identity_name,
878 role = %role,
879 tool = tool_name,
880 argument = arg_key,
881 arg_hmac = %policy.redact_arg(val_str),
882 "argument not in allowlist"
883 );
884 Some(
885 McpxError::Rbac(format!(
886 "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
887 ))
888 .into_response(),
889 )
890}
891
892fn json_value_type(v: &serde_json::Value) -> &'static str {
893 match v {
894 serde_json::Value::Null => "null",
895 serde_json::Value::Bool(_) => "bool",
896 serde_json::Value::Number(_) => "number",
897 serde_json::Value::String(_) => "string",
898 serde_json::Value::Array(_) => "array",
899 serde_json::Value::Object(_) => "object",
900 }
901}
902
903fn glob_match(pattern: &str, text: &str) -> bool {
908 let parts: Vec<&str> = pattern.split('*').collect();
909 if parts.len() == 1 {
910 return pattern == text;
912 }
913
914 let mut pos = 0;
915
916 if let Some(&first) = parts.first()
918 && !first.is_empty()
919 {
920 if !text.starts_with(first) {
921 return false;
922 }
923 pos = first.len();
924 }
925
926 if let Some(&last) = parts.last()
928 && !last.is_empty()
929 {
930 if !text[pos..].ends_with(last) {
931 return false;
932 }
933 let end = text.len() - last.len();
935 if pos > end {
936 return false;
937 }
938 let middle = &text[pos..end];
940 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
941 return match_middle(middle, middle_parts);
942 }
943
944 let middle = &text[pos..];
946 let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
947 match_middle(middle, middle_parts)
948}
949
950fn match_middle(mut text: &str, parts: &[&str]) -> bool {
952 for part in parts {
953 if part.is_empty() {
954 continue;
955 }
956 if let Some(idx) = text.find(part) {
957 text = &text[idx + part.len()..];
958 } else {
959 return false;
960 }
961 }
962 true
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 fn test_policy() -> RbacPolicy {
970 RbacPolicy::new(&RbacConfig {
971 enabled: true,
972 roles: vec![
973 RoleConfig {
974 name: "viewer".into(),
975 description: Some("Read-only".into()),
976 allow: vec![
977 "list_hosts".into(),
978 "resource_list".into(),
979 "resource_inspect".into(),
980 "resource_logs".into(),
981 "system_info".into(),
982 ],
983 deny: vec![],
984 hosts: vec!["*".into()],
985 argument_allowlists: vec![],
986 },
987 RoleConfig {
988 name: "deploy".into(),
989 description: Some("Lifecycle management".into()),
990 allow: vec![
991 "list_hosts".into(),
992 "resource_list".into(),
993 "resource_run".into(),
994 "resource_start".into(),
995 "resource_stop".into(),
996 "resource_restart".into(),
997 "resource_logs".into(),
998 "image_pull".into(),
999 ],
1000 deny: vec!["resource_delete".into(), "resource_exec".into()],
1001 hosts: vec!["web-*".into(), "api-*".into()],
1002 argument_allowlists: vec![],
1003 },
1004 RoleConfig {
1005 name: "ops".into(),
1006 description: Some("Full access".into()),
1007 allow: vec!["*".into()],
1008 deny: vec![],
1009 hosts: vec!["*".into()],
1010 argument_allowlists: vec![],
1011 },
1012 RoleConfig {
1013 name: "restricted-exec".into(),
1014 description: Some("Exec with argument allowlist".into()),
1015 allow: vec!["resource_exec".into()],
1016 deny: vec![],
1017 hosts: vec!["dev-*".into()],
1018 argument_allowlists: vec![ArgumentAllowlist {
1019 tool: "resource_exec".into(),
1020 argument: "cmd".into(),
1021 allowed: vec![
1022 "sh".into(),
1023 "bash".into(),
1024 "cat".into(),
1025 "ls".into(),
1026 "ps".into(),
1027 ],
1028 }],
1029 },
1030 ],
1031 redaction_salt: None,
1032 })
1033 }
1034
1035 #[test]
1038 fn glob_exact_match() {
1039 assert!(glob_match("web-prod-1", "web-prod-1"));
1040 assert!(!glob_match("web-prod-1", "web-prod-2"));
1041 }
1042
1043 #[test]
1044 fn glob_star_suffix() {
1045 assert!(glob_match("web-*", "web-prod-1"));
1046 assert!(glob_match("web-*", "web-staging"));
1047 assert!(!glob_match("web-*", "api-prod"));
1048 }
1049
1050 #[test]
1051 fn glob_star_prefix() {
1052 assert!(glob_match("*-prod", "web-prod"));
1053 assert!(glob_match("*-prod", "api-prod"));
1054 assert!(!glob_match("*-prod", "web-staging"));
1055 }
1056
1057 #[test]
1058 fn glob_star_middle() {
1059 assert!(glob_match("web-*-prod", "web-us-prod"));
1060 assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1061 assert!(!glob_match("web-*-prod", "web-staging"));
1062 }
1063
1064 #[test]
1065 fn glob_star_only() {
1066 assert!(glob_match("*", "anything"));
1067 assert!(glob_match("*", ""));
1068 }
1069
1070 #[test]
1071 fn glob_multiple_stars() {
1072 assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1073 assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1074 }
1075
1076 #[test]
1088 fn glob_prefix_and_suffix_meet_exactly() {
1089 assert!(glob_match("ab*cd", "abcd"));
1092 }
1093
1094 #[test]
1099 fn glob_middle_segment_required_with_suffix() {
1100 assert!(!glob_match("a*b*c", "axyc"));
1105 }
1106
1107 #[test]
1113 fn glob_match_middle_advances_past_matched_part() {
1114 assert!(!glob_match("*ab*ab*", "xxab_yz"));
1119 }
1120
1121 #[test]
1126 fn glob_match_middle_uses_addition_not_multiplication() {
1127 assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1131 }
1132
1133 #[test]
1142 fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1143 let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1151 .with_argument_allowlists(vec![ArgumentAllowlist::new(
1152 "run-*",
1153 "cmd",
1154 vec!["ls".into()],
1155 )]);
1156 let mut config = RbacConfig::with_roles(vec![role]);
1157 config.enabled = true;
1158 let policy = RbacPolicy::new(&config);
1159 assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1160 }
1161
1162 #[test]
1165 fn disabled_policy_allows_everything() {
1166 let policy = RbacPolicy::new(&RbacConfig {
1167 enabled: false,
1168 roles: vec![],
1169 redaction_salt: None,
1170 });
1171 assert_eq!(
1172 policy.check("nonexistent", "resource_delete", "any-host"),
1173 RbacDecision::Allow
1174 );
1175 }
1176
1177 #[test]
1178 fn unknown_role_denied() {
1179 let policy = test_policy();
1180 assert_eq!(
1181 policy.check("unknown", "resource_list", "web-prod-1"),
1182 RbacDecision::Deny
1183 );
1184 }
1185
1186 #[test]
1187 fn viewer_allowed_read_ops() {
1188 let policy = test_policy();
1189 assert_eq!(
1190 policy.check("viewer", "resource_list", "web-prod-1"),
1191 RbacDecision::Allow
1192 );
1193 assert_eq!(
1194 policy.check("viewer", "system_info", "db-host"),
1195 RbacDecision::Allow
1196 );
1197 }
1198
1199 #[test]
1200 fn viewer_denied_write_ops() {
1201 let policy = test_policy();
1202 assert_eq!(
1203 policy.check("viewer", "resource_run", "web-prod-1"),
1204 RbacDecision::Deny
1205 );
1206 assert_eq!(
1207 policy.check("viewer", "resource_delete", "web-prod-1"),
1208 RbacDecision::Deny
1209 );
1210 }
1211
1212 #[test]
1213 fn deploy_allowed_on_matching_hosts() {
1214 let policy = test_policy();
1215 assert_eq!(
1216 policy.check("deploy", "resource_run", "web-prod-1"),
1217 RbacDecision::Allow
1218 );
1219 assert_eq!(
1220 policy.check("deploy", "resource_start", "api-staging"),
1221 RbacDecision::Allow
1222 );
1223 }
1224
1225 #[test]
1226 fn deploy_denied_on_non_matching_host() {
1227 let policy = test_policy();
1228 assert_eq!(
1229 policy.check("deploy", "resource_run", "db-prod-1"),
1230 RbacDecision::Deny
1231 );
1232 }
1233
1234 #[test]
1235 fn deny_overrides_allow() {
1236 let policy = test_policy();
1237 assert_eq!(
1238 policy.check("deploy", "resource_delete", "web-prod-1"),
1239 RbacDecision::Deny
1240 );
1241 assert_eq!(
1242 policy.check("deploy", "resource_exec", "web-prod-1"),
1243 RbacDecision::Deny
1244 );
1245 }
1246
1247 #[test]
1248 fn ops_wildcard_allows_everything() {
1249 let policy = test_policy();
1250 assert_eq!(
1251 policy.check("ops", "resource_delete", "any-host"),
1252 RbacDecision::Allow
1253 );
1254 assert_eq!(
1255 policy.check("ops", "secret_create", "db-host"),
1256 RbacDecision::Allow
1257 );
1258 }
1259
1260 #[test]
1263 fn host_visible_respects_globs() {
1264 let policy = test_policy();
1265 assert!(policy.host_visible("deploy", "web-prod-1"));
1266 assert!(policy.host_visible("deploy", "api-staging"));
1267 assert!(!policy.host_visible("deploy", "db-prod-1"));
1268 assert!(policy.host_visible("ops", "anything"));
1269 assert!(policy.host_visible("viewer", "anything"));
1270 }
1271
1272 #[test]
1273 fn host_visible_unknown_role() {
1274 let policy = test_policy();
1275 assert!(!policy.host_visible("unknown", "web-prod-1"));
1276 }
1277
1278 #[test]
1281 fn argument_allowed_no_allowlist() {
1282 let policy = test_policy();
1283 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1285 assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1286 }
1287
1288 #[test]
1289 fn argument_allowed_with_allowlist() {
1290 let policy = test_policy();
1291 assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1292 assert!(policy.argument_allowed(
1293 "restricted-exec",
1294 "resource_exec",
1295 "cmd",
1296 "bash -c 'echo hi'"
1297 ));
1298 assert!(policy.argument_allowed(
1299 "restricted-exec",
1300 "resource_exec",
1301 "cmd",
1302 "cat /etc/hosts"
1303 ));
1304 assert!(policy.argument_allowed(
1305 "restricted-exec",
1306 "resource_exec",
1307 "cmd",
1308 "/usr/bin/ls -la"
1309 ));
1310 }
1311
1312 #[test]
1313 fn argument_denied_not_in_allowlist() {
1314 let policy = test_policy();
1315 assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1316 assert!(!policy.argument_allowed(
1317 "restricted-exec",
1318 "resource_exec",
1319 "cmd",
1320 "python3 exploit.py"
1321 ));
1322 assert!(!policy.argument_allowed(
1323 "restricted-exec",
1324 "resource_exec",
1325 "cmd",
1326 "/usr/bin/curl evil.com"
1327 ));
1328 }
1329
1330 #[test]
1331 fn argument_denied_unknown_role() {
1332 let policy = test_policy();
1333 assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1334 }
1335
1336 fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1345 let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1346 .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1347 let mut config = RbacConfig::with_roles(vec![role]);
1348 config.enabled = true;
1349 RbacPolicy::new(&config)
1350 }
1351
1352 #[test]
1353 fn argument_allowed_matches_quoted_path_with_spaces() {
1354 let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1355 assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1356 }
1357
1358 #[test]
1359 fn argument_allowed_matches_basename_of_quoted_path() {
1360 let policy = shlex_policy(vec!["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_fails_closed_on_unbalanced_quote() {
1366 let policy = shlex_policy(vec!["unbalanced".into()]);
1367 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1368 }
1369
1370 #[test]
1371 fn argument_allowed_fails_closed_on_empty_string() {
1372 let policy = shlex_policy(vec![String::new()]);
1373 assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1374 }
1375
1376 #[test]
1377 fn argument_allowed_handles_single_quoted_executable() {
1378 let policy = shlex_policy(vec!["/bin/sh".into()]);
1379 assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1380 }
1381
1382 #[test]
1383 fn argument_allowed_handles_tab_separator() {
1384 let policy = shlex_policy(vec!["ls".into()]);
1385 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1386 }
1387
1388 #[test]
1389 fn argument_allowed_plain_token_unchanged() {
1390 let policy = shlex_policy(vec!["ls".into()]);
1391 assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1392 }
1393
1394 #[test]
1400 fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1401 let policy = shlex_policy(vec![String::new()]);
1405 assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1406 }
1407
1408 #[test]
1409 fn argument_allowed_quoted_literal_token_no_longer_matches() {
1410 let policy = shlex_policy(vec!["'bash'".into()]);
1416 assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1417 }
1418
1419 #[test]
1420 fn argument_allowed_backslash_literal_token_no_longer_matches() {
1421 let policy = shlex_policy(vec![r"foo\bar".into()]);
1426 assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1427 }
1428
1429 #[test]
1430 fn argument_allowed_windows_path_no_longer_matches() {
1431 let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1436 assert!(!policy.argument_allowed(
1437 "viewer",
1438 "run",
1439 "cmd",
1440 r"C:\Windows\System32\cmd.exe /c dir"
1441 ));
1442 }
1443
1444 #[test]
1447 fn host_patterns_returns_globs() {
1448 let policy = test_policy();
1449 assert_eq!(
1450 policy.host_patterns("deploy"),
1451 Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1452 );
1453 assert_eq!(
1454 policy.host_patterns("ops"),
1455 Some(vec!["*".to_owned()].as_slice())
1456 );
1457 assert!(policy.host_patterns("nonexistent").is_none());
1458 }
1459
1460 #[test]
1463 fn check_operation_allows_without_host() {
1464 let policy = test_policy();
1465 assert_eq!(
1466 policy.check_operation("deploy", "resource_run"),
1467 RbacDecision::Allow
1468 );
1469 assert_eq!(
1471 policy.check("deploy", "resource_run", "db-prod-1"),
1472 RbacDecision::Deny
1473 );
1474 }
1475
1476 #[test]
1477 fn check_operation_deny_overrides() {
1478 let policy = test_policy();
1479 assert_eq!(
1480 policy.check_operation("deploy", "resource_delete"),
1481 RbacDecision::Deny
1482 );
1483 }
1484
1485 #[test]
1486 fn check_operation_unknown_role() {
1487 let policy = test_policy();
1488 assert_eq!(
1489 policy.check_operation("unknown", "resource_list"),
1490 RbacDecision::Deny
1491 );
1492 }
1493
1494 #[test]
1495 fn check_operation_disabled() {
1496 let policy = RbacPolicy::new(&RbacConfig {
1497 enabled: false,
1498 roles: vec![],
1499 redaction_salt: None,
1500 });
1501 assert_eq!(
1502 policy.check_operation("nonexistent", "anything"),
1503 RbacDecision::Allow
1504 );
1505 }
1506
1507 #[test]
1510 fn current_role_returns_none_outside_scope() {
1511 assert!(current_role().is_none());
1512 }
1513
1514 #[test]
1515 fn current_identity_returns_none_outside_scope() {
1516 assert!(current_identity().is_none());
1517 }
1518
1519 use axum::{
1522 body::Body,
1523 http::{Method, Request, StatusCode},
1524 };
1525 use tower::ServiceExt as _;
1526
1527 fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1528 serde_json::json!({
1529 "jsonrpc": "2.0",
1530 "id": 1,
1531 "method": "tools/call",
1532 "params": {
1533 "name": tool,
1534 "arguments": args
1535 }
1536 })
1537 .to_string()
1538 }
1539
1540 fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1541 axum::Router::new()
1542 .route("/mcp", axum::routing::post(|| async { "ok" }))
1543 .layer(axum::middleware::from_fn(move |req, next| {
1544 let p = Arc::clone(&policy);
1545 rbac_middleware(p, None, req, next)
1546 }))
1547 }
1548
1549 fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1550 axum::Router::new()
1551 .route("/mcp", axum::routing::post(|| async { "ok" }))
1552 .layer(axum::middleware::from_fn(
1553 move |mut req: Request<Body>, next: Next| {
1554 let p = Arc::clone(&policy);
1555 let id = identity.clone();
1556 async move {
1557 req.extensions_mut().insert(id);
1558 rbac_middleware(p, None, req, next).await
1559 }
1560 },
1561 ))
1562 }
1563
1564 #[tokio::test]
1565 async fn middleware_passes_non_post() {
1566 let policy = Arc::new(test_policy());
1567 let app = rbac_router(policy);
1568 let req = Request::builder()
1570 .method(Method::GET)
1571 .uri("/mcp")
1572 .body(Body::empty())
1573 .unwrap();
1574 let resp = app.oneshot(req).await.unwrap();
1577 assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1578 }
1579
1580 #[tokio::test]
1581 async fn middleware_denies_without_identity() {
1582 let policy = Arc::new(test_policy());
1583 let app = rbac_router(policy);
1584 let body = tool_call_body("resource_list", &serde_json::json!({}));
1585 let req = Request::builder()
1586 .method(Method::POST)
1587 .uri("/mcp")
1588 .header("content-type", "application/json")
1589 .body(Body::from(body))
1590 .unwrap();
1591 let resp = app.oneshot(req).await.unwrap();
1592 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1593 }
1594
1595 #[tokio::test]
1596 async fn middleware_allows_permitted_tool() {
1597 let policy = Arc::new(test_policy());
1598 let id = AuthIdentity {
1599 method: crate::auth::AuthMethod::BearerToken,
1600 name: "alice".into(),
1601 role: "viewer".into(),
1602 raw_token: None,
1603 sub: None,
1604 };
1605 let app = rbac_router_with_identity(policy, id);
1606 let body = tool_call_body("resource_list", &serde_json::json!({}));
1607 let req = Request::builder()
1608 .method(Method::POST)
1609 .uri("/mcp")
1610 .header("content-type", "application/json")
1611 .body(Body::from(body))
1612 .unwrap();
1613 let resp = app.oneshot(req).await.unwrap();
1614 assert_eq!(resp.status(), StatusCode::OK);
1615 }
1616
1617 #[tokio::test]
1618 async fn middleware_denies_unpermitted_tool() {
1619 let policy = Arc::new(test_policy());
1620 let id = AuthIdentity {
1621 method: crate::auth::AuthMethod::BearerToken,
1622 name: "alice".into(),
1623 role: "viewer".into(),
1624 raw_token: None,
1625 sub: None,
1626 };
1627 let app = rbac_router_with_identity(policy, id);
1628 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1629 let req = Request::builder()
1630 .method(Method::POST)
1631 .uri("/mcp")
1632 .header("content-type", "application/json")
1633 .body(Body::from(body))
1634 .unwrap();
1635 let resp = app.oneshot(req).await.unwrap();
1636 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1637 }
1638
1639 #[tokio::test]
1640 async fn middleware_passes_non_tool_call_post() {
1641 let policy = Arc::new(test_policy());
1642 let id = AuthIdentity {
1643 method: crate::auth::AuthMethod::BearerToken,
1644 name: "alice".into(),
1645 role: "viewer".into(),
1646 raw_token: None,
1647 sub: None,
1648 };
1649 let app = rbac_router_with_identity(policy, id);
1650 let body = serde_json::json!({
1652 "jsonrpc": "2.0",
1653 "id": 1,
1654 "method": "resources/list"
1655 })
1656 .to_string();
1657 let req = Request::builder()
1658 .method(Method::POST)
1659 .uri("/mcp")
1660 .header("content-type", "application/json")
1661 .body(Body::from(body))
1662 .unwrap();
1663 let resp = app.oneshot(req).await.unwrap();
1664 assert_eq!(resp.status(), StatusCode::OK);
1665 }
1666
1667 #[tokio::test]
1668 async fn middleware_enforces_argument_allowlist() {
1669 let policy = Arc::new(test_policy());
1670 let id = AuthIdentity {
1671 method: crate::auth::AuthMethod::BearerToken,
1672 name: "dev".into(),
1673 role: "restricted-exec".into(),
1674 raw_token: None,
1675 sub: None,
1676 };
1677 let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1679 let body = tool_call_body(
1680 "resource_exec",
1681 &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1682 );
1683 let req = Request::builder()
1684 .method(Method::POST)
1685 .uri("/mcp")
1686 .body(Body::from(body))
1687 .unwrap();
1688 let resp = app.oneshot(req).await.unwrap();
1689 assert_eq!(resp.status(), StatusCode::OK);
1690
1691 let app = rbac_router_with_identity(policy, id);
1693 let body = tool_call_body(
1694 "resource_exec",
1695 &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1696 );
1697 let req = Request::builder()
1698 .method(Method::POST)
1699 .uri("/mcp")
1700 .body(Body::from(body))
1701 .unwrap();
1702 let resp = app.oneshot(req).await.unwrap();
1703 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1704 }
1705
1706 #[tokio::test]
1707 async fn middleware_disabled_policy_passes_everything() {
1708 let policy = Arc::new(RbacPolicy::disabled());
1709 let app = rbac_router(policy);
1710 let body = tool_call_body("anything", &serde_json::json!({}));
1712 let req = Request::builder()
1713 .method(Method::POST)
1714 .uri("/mcp")
1715 .body(Body::from(body))
1716 .unwrap();
1717 let resp = app.oneshot(req).await.unwrap();
1718 assert_eq!(resp.status(), StatusCode::OK);
1719 }
1720
1721 #[tokio::test]
1722 async fn middleware_batch_all_allowed_passes() {
1723 let policy = Arc::new(test_policy());
1724 let id = AuthIdentity {
1725 method: crate::auth::AuthMethod::BearerToken,
1726 name: "alice".into(),
1727 role: "viewer".into(),
1728 raw_token: None,
1729 sub: None,
1730 };
1731 let app = rbac_router_with_identity(policy, id);
1732 let body = serde_json::json!([
1733 {
1734 "jsonrpc": "2.0",
1735 "id": 1,
1736 "method": "tools/call",
1737 "params": { "name": "resource_list", "arguments": {} }
1738 },
1739 {
1740 "jsonrpc": "2.0",
1741 "id": 2,
1742 "method": "tools/call",
1743 "params": { "name": "system_info", "arguments": {} }
1744 }
1745 ])
1746 .to_string();
1747 let req = Request::builder()
1748 .method(Method::POST)
1749 .uri("/mcp")
1750 .header("content-type", "application/json")
1751 .body(Body::from(body))
1752 .unwrap();
1753 let resp = app.oneshot(req).await.unwrap();
1754 assert_eq!(resp.status(), StatusCode::OK);
1755 }
1756
1757 #[tokio::test]
1758 async fn middleware_batch_with_denied_call_rejects_entire_batch() {
1759 let policy = Arc::new(test_policy());
1760 let id = AuthIdentity {
1761 method: crate::auth::AuthMethod::BearerToken,
1762 name: "alice".into(),
1763 role: "viewer".into(),
1764 raw_token: None,
1765 sub: None,
1766 };
1767 let app = rbac_router_with_identity(policy, id);
1768 let body = serde_json::json!([
1769 {
1770 "jsonrpc": "2.0",
1771 "id": 1,
1772 "method": "tools/call",
1773 "params": { "name": "resource_list", "arguments": {} }
1774 },
1775 {
1776 "jsonrpc": "2.0",
1777 "id": 2,
1778 "method": "tools/call",
1779 "params": { "name": "resource_delete", "arguments": {} }
1780 }
1781 ])
1782 .to_string();
1783 let req = Request::builder()
1784 .method(Method::POST)
1785 .uri("/mcp")
1786 .header("content-type", "application/json")
1787 .body(Body::from(body))
1788 .unwrap();
1789 let resp = app.oneshot(req).await.unwrap();
1790 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1791 }
1792
1793 #[tokio::test]
1794 async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1795 let policy = Arc::new(test_policy());
1796 let id = AuthIdentity {
1797 method: crate::auth::AuthMethod::BearerToken,
1798 name: "dev".into(),
1799 role: "restricted-exec".into(),
1800 raw_token: None,
1801 sub: None,
1802 };
1803 let app = rbac_router_with_identity(policy, id);
1804 let body = serde_json::json!([
1805 {
1806 "jsonrpc": "2.0",
1807 "id": 1,
1808 "method": "tools/call",
1809 "params": {
1810 "name": "resource_exec",
1811 "arguments": { "cmd": "ls -la", "host": "dev-1" }
1812 }
1813 },
1814 {
1815 "jsonrpc": "2.0",
1816 "id": 2,
1817 "method": "tools/call",
1818 "params": {
1819 "name": "resource_exec",
1820 "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1821 }
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 #[test]
1838 fn redact_with_salt_is_deterministic_per_salt() {
1839 let salt = b"unit-test-salt";
1840 let a = redact_with_salt(salt, "rm -rf /");
1841 let b = redact_with_salt(salt, "rm -rf /");
1842 assert_eq!(a, b, "same input + salt must yield identical hash");
1843 assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1844 assert!(
1845 a.chars().all(|c| c.is_ascii_hexdigit()),
1846 "redacted hash must be lowercase hex: {a}"
1847 );
1848 }
1849
1850 #[test]
1851 fn redact_with_salt_differs_across_salts() {
1852 let v = "the-same-value";
1853 let h1 = redact_with_salt(b"salt-one", v);
1854 let h2 = redact_with_salt(b"salt-two", v);
1855 assert_ne!(
1856 h1, h2,
1857 "different salts must produce different hashes for the same value"
1858 );
1859 }
1860
1861 #[test]
1862 fn redact_with_salt_distinguishes_values() {
1863 let salt = b"k";
1864 let h1 = redact_with_salt(salt, "alpha");
1865 let h2 = redact_with_salt(salt, "beta");
1866 assert_ne!(h1, h2, "different values must produce different hashes");
1868 }
1869
1870 #[test]
1871 fn policy_with_configured_salt_redacts_consistently() {
1872 let cfg = RbacConfig {
1873 enabled: true,
1874 roles: vec![],
1875 redaction_salt: Some(SecretString::from("my-stable-salt")),
1876 };
1877 let p1 = RbacPolicy::new(&cfg);
1878 let p2 = RbacPolicy::new(&cfg);
1879 assert_eq!(
1880 p1.redact_arg("payload"),
1881 p2.redact_arg("payload"),
1882 "policies built from the same configured salt must agree"
1883 );
1884 }
1885
1886 #[test]
1887 fn policy_without_configured_salt_uses_process_salt() {
1888 let cfg = RbacConfig {
1889 enabled: true,
1890 roles: vec![],
1891 redaction_salt: None,
1892 };
1893 let p1 = RbacPolicy::new(&cfg);
1894 let p2 = RbacPolicy::new(&cfg);
1895 assert_eq!(
1897 p1.redact_arg("payload"),
1898 p2.redact_arg("payload"),
1899 "process-wide salt must be consistent within one process"
1900 );
1901 }
1902
1903 #[test]
1904 fn redact_arg_is_fast_enough() {
1905 let salt = b"perf-sanity-salt-32-bytes-padded";
1909 let value = "x".repeat(256);
1910 let start = std::time::Instant::now();
1911 let _ = redact_with_salt(salt, &value);
1912 let elapsed = start.elapsed();
1913 assert!(
1914 elapsed < Duration::from_millis(5),
1915 "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
1916 );
1917 }
1918
1919 #[tokio::test]
1931 async fn deny_path_uses_explicit_identity_not_task_local() {
1932 let policy = Arc::new(test_policy());
1933 let id = AuthIdentity {
1934 method: crate::auth::AuthMethod::BearerToken,
1935 name: "alice-the-auditor".into(),
1936 role: "viewer".into(),
1937 raw_token: None,
1938 sub: None,
1939 };
1940 let app = rbac_router_with_identity(policy, id);
1941 let body = tool_call_body("resource_delete", &serde_json::json!({}));
1943 let req = Request::builder()
1944 .method(Method::POST)
1945 .uri("/mcp")
1946 .header("content-type", "application/json")
1947 .body(Body::from(body))
1948 .unwrap();
1949 let resp = app.oneshot(req).await.unwrap();
1950 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1951 }
1952
1953 fn restricted_exec_identity() -> AuthIdentity {
1956 AuthIdentity {
1957 method: crate::auth::AuthMethod::BearerToken,
1958 name: "carol".into(),
1959 role: "restricted-exec".into(),
1960 raw_token: None,
1961 sub: None,
1962 }
1963 }
1964
1965 #[test]
1966 fn has_argument_allowlist_matches_configured_tool_argument() {
1967 let policy = test_policy();
1968 assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
1969 assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
1970 assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
1971 assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
1972 }
1973
1974 #[tokio::test]
1975 async fn array_arg_with_matching_allowlist_is_denied() {
1976 let policy = Arc::new(test_policy());
1977 let app = rbac_router_with_identity(policy, restricted_exec_identity());
1978 let body = tool_call_body(
1979 "resource_exec",
1980 &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
1981 );
1982 let req = Request::builder()
1983 .method(Method::POST)
1984 .uri("/mcp")
1985 .header("content-type", "application/json")
1986 .body(Body::from(body))
1987 .unwrap();
1988 let resp = app.oneshot(req).await.unwrap();
1989 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1990 }
1991
1992 #[tokio::test]
1993 async fn object_arg_with_matching_allowlist_is_denied() {
1994 let policy = Arc::new(test_policy());
1995 let app = rbac_router_with_identity(policy, restricted_exec_identity());
1996 let body = tool_call_body(
1997 "resource_exec",
1998 &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
1999 );
2000 let req = Request::builder()
2001 .method(Method::POST)
2002 .uri("/mcp")
2003 .header("content-type", "application/json")
2004 .body(Body::from(body))
2005 .unwrap();
2006 let resp = app.oneshot(req).await.unwrap();
2007 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2008 }
2009
2010 #[tokio::test]
2011 async fn number_arg_with_matching_allowlist_is_denied() {
2012 let policy = Arc::new(test_policy());
2013 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2014 let body = tool_call_body(
2015 "resource_exec",
2016 &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2017 );
2018 let req = Request::builder()
2019 .method(Method::POST)
2020 .uri("/mcp")
2021 .header("content-type", "application/json")
2022 .body(Body::from(body))
2023 .unwrap();
2024 let resp = app.oneshot(req).await.unwrap();
2025 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2026 }
2027
2028 #[tokio::test]
2029 async fn bool_arg_with_matching_allowlist_is_denied() {
2030 let policy = Arc::new(test_policy());
2031 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2032 let body = tool_call_body(
2033 "resource_exec",
2034 &serde_json::json!({ "host": "dev-1", "cmd": true }),
2035 );
2036 let req = Request::builder()
2037 .method(Method::POST)
2038 .uri("/mcp")
2039 .header("content-type", "application/json")
2040 .body(Body::from(body))
2041 .unwrap();
2042 let resp = app.oneshot(req).await.unwrap();
2043 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2044 }
2045
2046 #[tokio::test]
2047 async fn null_arg_with_matching_allowlist_is_denied() {
2048 let policy = Arc::new(test_policy());
2049 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2050 let body = tool_call_body(
2051 "resource_exec",
2052 &serde_json::json!({ "host": "dev-1", "cmd": null }),
2053 );
2054 let req = Request::builder()
2055 .method(Method::POST)
2056 .uri("/mcp")
2057 .header("content-type", "application/json")
2058 .body(Body::from(body))
2059 .unwrap();
2060 let resp = app.oneshot(req).await.unwrap();
2061 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2062 }
2063
2064 #[tokio::test]
2065 async fn non_string_arg_without_allowlist_is_passthrough() {
2066 let policy = Arc::new(test_policy());
2070 let id = AuthIdentity {
2071 method: crate::auth::AuthMethod::BearerToken,
2072 name: "olivia".into(),
2073 role: "ops".into(),
2074 raw_token: None,
2075 sub: None,
2076 };
2077 let app = rbac_router_with_identity(policy, id);
2078 let body = tool_call_body(
2079 "resource_exec",
2080 &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2081 );
2082 let req = Request::builder()
2083 .method(Method::POST)
2084 .uri("/mcp")
2085 .header("content-type", "application/json")
2086 .body(Body::from(body))
2087 .unwrap();
2088 let resp = app.oneshot(req).await.unwrap();
2089 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2090 }
2091
2092 #[tokio::test]
2093 async fn string_arg_in_allowlist_still_passes() {
2094 let policy = Arc::new(test_policy());
2095 let app = rbac_router_with_identity(policy, restricted_exec_identity());
2096 let body = tool_call_body(
2097 "resource_exec",
2098 &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2099 );
2100 let req = Request::builder()
2101 .method(Method::POST)
2102 .uri("/mcp")
2103 .header("content-type", "application/json")
2104 .body(Body::from(body))
2105 .unwrap();
2106 let resp = app.oneshot(req).await.unwrap();
2107 assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2108 }
2109}