Skip to main content

rmcp_server_kit/
rbac.rs

1//! Role-Based Access Control (RBAC) policy engine.
2//!
3//! Evaluates `(role, operation, host)` tuples against a set of role
4//! definitions loaded from config.  Deny-overrides-allow semantics:
5//! an explicit deny entry always wins over a wildcard allow.
6//!
7//! Includes an axum middleware that inspects MCP JSON-RPC tool calls
8//! and enforces RBAC and per-IP tool rate limiting before the request
9//! reaches the handler.
10
11use 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
32/// Per-source-IP rate limiter for tool invocations. Memory-bounded against
33/// IP-spray `DoS` via [`BoundedKeyedLimiter`].
34pub(crate) type ToolRateLimiter = BoundedKeyedLimiter<IpAddr>;
35
36/// Default tool rate limit: 120 invocations per minute per source IP.
37// SAFETY: unwrap() is safe - literal 120 is provably non-zero (const-evaluated).
38const DEFAULT_TOOL_RATE: NonZeroU32 = NonZeroU32::new(120).unwrap();
39
40/// Default cap on the number of distinct source IPs tracked by the tool
41/// rate limiter. Bounded to defend against IP-spray `DoS` exhausting memory.
42const DEFAULT_TOOL_MAX_TRACKED_KEYS: usize = 10_000;
43
44/// Default idle-eviction window for the tool rate limiter (15 minutes).
45const DEFAULT_TOOL_IDLE_EVICTION: Duration = Duration::from_mins(15);
46
47/// Build a per-IP tool rate limiter from a max-calls-per-minute value.
48///
49/// Memory-bounded with `DEFAULT_TOOL_MAX_TRACKED_KEYS` tracked keys and
50/// `DEFAULT_TOOL_IDLE_EVICTION` idle eviction. Use
51/// [`build_tool_rate_limiter_with_bounds`] to override.
52#[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/// Build a per-IP tool rate limiter with explicit memory-bound parameters.
62#[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
77// Task-local storage for the current caller's RBAC role and identity name.
78// Set by the RBAC middleware, read by tool handlers (e.g. list_hosts filtering, audit logging).
79//
80// `CURRENT_TOKEN` holds a [`SecretString`] so the raw bearer token is never
81// printed via `Debug` (it formats as `"[REDACTED alloc::string::String]"`)
82// and is zeroized on drop by the `secrecy` crate.
83tokio::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/// Get the current caller's RBAC role (set by RBAC middleware).
91/// Returns `None` outside an RBAC-scoped request context.
92#[must_use]
93pub fn current_role() -> Option<String> {
94    CURRENT_ROLE.try_with(Clone::clone).ok()
95}
96
97/// Get the current caller's identity name (set by RBAC middleware).
98/// Returns `None` outside an RBAC-scoped request context.
99#[must_use]
100pub fn current_identity() -> Option<String> {
101    CURRENT_IDENTITY.try_with(Clone::clone).ok()
102}
103
104/// Get the raw bearer token for the current request as a [`SecretString`].
105/// Returns `None` outside a request context or when auth used mTLS/API-key.
106/// Tool handlers use this for downstream token passthrough.
107///
108/// The returned value is wrapped in [`SecretString`] so it does not leak
109/// via `Debug`/`Display`/serde. Call `.expose_secret()` only when the
110/// raw value is actually needed (e.g. as the `Authorization` header on
111/// an outbound HTTP request).
112///
113/// An empty token is treated as absent (returns `None`); this preserves
114/// backward compatibility with the prior `Option<String>` API where the
115/// empty default sentinel meant "no token".
116#[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/// Get the JWT `sub` claim (stable user ID, e.g. Keycloak UUID).
131/// Returns `None` outside a request context or for non-JWT auth.
132/// Use for stable per-user keying (token store, etc.).
133#[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
141/// Run a future with `CURRENT_TOKEN` set so that [`current_token()`] returns
142/// the given value inside the future. Useful when MCP tool handlers need the
143/// raw bearer token but run in a spawned task where the RBAC middleware's
144/// task-local scope is no longer active.
145pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
146    CURRENT_TOKEN.scope(token, f).await
147}
148
149/// Run a future with all task-locals (`CURRENT_ROLE`, `CURRENT_IDENTITY`,
150/// `CURRENT_TOKEN`, `CURRENT_SUB`) set.  Use this when re-establishing the
151/// full RBAC context in spawned tasks (e.g. rmcp session tasks) where the
152/// middleware's scope is no longer active.
153pub 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/// A single role definition.
172#[derive(Debug, Clone, Deserialize)]
173#[non_exhaustive]
174pub struct RoleConfig {
175    /// Role identifier referenced from identities (API keys, mTLS, JWT claims).
176    pub name: String,
177    /// Human-readable description, surfaced in diagnostics only.
178    #[serde(default)]
179    pub description: Option<String>,
180    /// Allowed operations.  `["*"]` means all operations.
181    #[serde(default)]
182    pub allow: Vec<String>,
183    /// Explicitly denied operations (overrides allow).
184    #[serde(default)]
185    pub deny: Vec<String>,
186    /// Host name glob patterns this role can access. `["*"]` means all hosts.
187    #[serde(default = "default_hosts")]
188    pub hosts: Vec<String>,
189    /// Per-tool argument constraints. When a tool call matches, the
190    /// specified argument's first whitespace-delimited token (or its
191    /// `/`-basename) must appear in the allowlist.
192    #[serde(default)]
193    pub argument_allowlists: Vec<ArgumentAllowlist>,
194}
195
196impl RoleConfig {
197    /// Create a role with the given name, allowed operations, and host patterns.
198    #[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    /// Attach argument allowlists to this role.
211    #[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/// Per-tool argument allowlist entry.
219///
220/// When the middleware sees a `tools/call` for `tool`, it extracts the
221/// string value at `argument` from the call's arguments object and checks
222/// its first token against `allowed`. If the token is not in the list
223/// the call is rejected with 403.
224#[derive(Debug, Clone, Deserialize)]
225#[non_exhaustive]
226pub struct ArgumentAllowlist {
227    /// Tool name to match (exact or glob, e.g. `"run_query"`).
228    pub tool: String,
229    /// Argument key whose value is checked (e.g. `"cmd"`, `"query"`).
230    pub argument: String,
231    /// Permitted first-token values. Empty means unrestricted.
232    #[serde(default)]
233    pub allowed: Vec<String>,
234}
235
236impl ArgumentAllowlist {
237    /// Create an argument allowlist for a tool.
238    #[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/// Top-level RBAC configuration (deserializable from TOML).
253#[derive(Debug, Clone, Default, Deserialize)]
254#[non_exhaustive]
255pub struct RbacConfig {
256    /// Master switch -- when false, the RBAC middleware is not installed.
257    #[serde(default)]
258    pub enabled: bool,
259    /// Role definitions available to identities.
260    #[serde(default)]
261    pub roles: Vec<RoleConfig>,
262    /// Optional stable HMAC key (any length) used to redact argument
263    /// values in deny logs. When set, redacted hashes are stable across
264    /// process restarts (useful for log correlation across deploys).
265    /// When `None`, a random 32-byte key is generated per process at
266    /// first use; redacted hashes change every restart.
267    ///
268    /// The key is wrapped in [`SecretString`] so it never leaks via
269    /// `Debug`/`Display`/serde and is zeroized on drop.
270    #[serde(default)]
271    pub redaction_salt: Option<SecretString>,
272}
273
274impl RbacConfig {
275    /// Create an enabled RBAC config with the given roles.
276    #[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/// Result of an RBAC policy check.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288#[non_exhaustive]
289pub enum RbacDecision {
290    /// Caller is permitted to perform the requested operation.
291    Allow,
292    /// Caller is denied access.
293    Deny,
294}
295
296/// Summary of a single role, produced by [`RbacPolicy::summary`].
297#[derive(Debug, Clone, serde::Serialize)]
298#[non_exhaustive]
299pub struct RbacRoleSummary {
300    /// Role name.
301    pub name: String,
302    /// Number of allow entries.
303    pub allow: usize,
304    /// Number of deny entries.
305    pub deny: usize,
306    /// Number of host patterns.
307    pub hosts: usize,
308    /// Number of argument allowlist entries.
309    pub argument_allowlists: usize,
310}
311
312/// Summary of the whole RBAC policy, produced by [`RbacPolicy::summary`].
313#[derive(Debug, Clone, serde::Serialize)]
314#[non_exhaustive]
315pub struct RbacPolicySummary {
316    /// Whether RBAC enforcement is active.
317    pub enabled: bool,
318    /// Per-role summaries.
319    pub roles: Vec<RbacRoleSummary>,
320}
321
322/// Compiled RBAC policy for fast lookup.
323///
324/// Built from [`RbacConfig`] at startup.  All lookups are O(n) over the
325/// role's allow/deny/host lists, which is fine for the expected cardinality
326/// (a handful of roles with tens of entries each).
327#[derive(Debug, Clone)]
328#[non_exhaustive]
329pub struct RbacPolicy {
330    roles: Vec<RoleConfig>,
331    enabled: bool,
332    /// HMAC key used to redact argument values in deny logs.
333    /// Either a configured stable salt or a per-process random salt.
334    redaction_salt: Arc<SecretString>,
335}
336
337impl RbacPolicy {
338    /// Build a policy from config.  When `config.enabled` is false, all
339    /// checks return [`RbacDecision::Allow`].
340    #[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    /// Create a policy that always allows (RBAC disabled).
354    #[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    /// Whether RBAC enforcement is active.
364    #[must_use]
365    pub fn is_enabled(&self) -> bool {
366        self.enabled
367    }
368
369    /// Summarize the policy for diagnostics (admin endpoint).
370    ///
371    /// Returns `(enabled, role_count, per_role_stats)` where each stat is
372    /// `(name, allow_count, deny_count, host_count, argument_allowlist_count)`.
373    #[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    /// Check whether `role` may perform `operation` (ignoring host).
393    ///
394    /// Use this for tools that don't target a specific host (e.g. `ping`,
395    /// `list_hosts`).
396    #[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    /// Check whether `role` may perform `operation` on `host`.
414    ///
415    /// Evaluation order:
416    /// 1. If RBAC is disabled, allow.
417    /// 2. Check operation permission (deny overrides allow).
418    /// 3. Check host visibility via glob matching.
419    #[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    /// Check whether `role` can see `host` at all (for `list_hosts` filtering).
440    #[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    /// Get the list of hosts patterns for a role.
452    #[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    /// Check whether `value` passes the argument allowlists for `tool` under `role`.
458    ///
459    /// If the role has no matching `argument_allowlists` entry for the tool,
460    /// all values are allowed. When a matching entry exists, `value` is
461    /// tokenized using POSIX-shell-like lexical rules ([`shlex::split`])
462    /// and its first argv element (or the `/`-basename of that element)
463    /// must appear in the `allowed` list.
464    ///
465    /// **Scope of the contract.** This matcher targets consumers that
466    /// interpret string arguments as POSIX-shell-like command lines on
467    /// Unix-like systems (e.g. anything that subsequently feeds the value
468    /// through `shlex` or an equivalent splitter before `execve`). It
469    /// does **not** model real shell *execution* grammar (`FOO=1 cmd`,
470    /// expansion, command substitution, redirection, operators) or
471    /// Windows command-line tokenization (`CommandLineToArgvW`,
472    /// `cmd.exe`, PowerShell). Consumers in those regimes remain subject
473    /// to a parser differential and must validate at their own boundary.
474    ///
475    /// **Fail-closed cases (all return `false` when a matching allowlist
476    /// entry exists):**
477    ///
478    /// - `value` fails to parse as a POSIX-shell-like command line
479    ///   (e.g. unbalanced quotes, dangling escape).
480    /// - `value` parses to zero tokens (empty input).
481    /// - The first parsed token is the empty string (e.g.
482    ///   `value = r#""""#` parses to `Some(vec![""])`). An empty argv
483    ///   element is never a runnable executable, so we reject even when
484    ///   `""` is in the allowlist.
485    #[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            // Tokenize per POSIX-shell-like rules so quoted paths with
504            // spaces match what an equivalently-tokenizing consumer
505            // would actually run, and malformed shell syntax (unbalanced
506            // quotes, dangling escapes) fails closed.
507            let Some(tokens) = shlex::split(value) else {
508                return false;
509            };
510            let Some(first_token) = tokens.first() else {
511                return false;
512            };
513            // A well-formed but empty first argv element (e.g.
514            // value = r#""""#) is never a runnable executable. Fail
515            // closed even if "" appears in the allowlist.
516            if first_token.is_empty() {
517                return false;
518            }
519            // Also match against the basename if it's a path. POSIX
520            // separator only; Windows-style backslash paths are out of
521            // scope and will not basename-match (see crate-level docs).
522            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    /// Return `true` if `(role, tool, argument)` has any non-empty
534    /// allowlist entry configured.
535    ///
536    /// Used by the tools/call middleware to decide whether non-string
537    /// JSON values must be rejected (M2 fix). When this returns `true`,
538    /// the value at `argument` must be a JSON string and pass
539    /// [`Self::argument_allowed`]; otherwise the call is denied with
540    /// 403. When this returns `false`, the value is unconstrained by
541    /// allowlist policy.
542    #[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    /// Return the role config for a given role name.
558    fn find_role(&self, name: &str) -> Option<&RoleConfig> {
559        self.roles.iter().find(|r| r.name == name)
560    }
561
562    /// Check if a host name matches any of the given glob patterns.
563    fn host_matches(patterns: &[String], host: &str) -> bool {
564        patterns.iter().any(|p| glob_match(p, host))
565    }
566
567    /// HMAC-SHA256 the given argument value with this policy's redaction
568    /// salt and return the first 8 hex characters (4 bytes / 32 bits).
569    ///
570    /// 32 bits is enough entropy for log correlation (1-in-4-billion
571    /// collision per pair) while being far short of any preimage attack
572    /// surface for an attacker reading logs. The HMAC construction
573    /// guarantees that even short or low-entropy values cannot be
574    /// recovered without the key.
575    #[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
581/// Process-wide random redaction salt, lazily generated on first use.
582/// Used when [`RbacConfig::redaction_salt`] is `None`.
583fn 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        // base64-encode so the SecretString is valid UTF-8; the HMAC
590        // accepts arbitrary key bytes regardless.
591        SecretString::from(STANDARD_NO_PAD.encode(bytes))
592    })
593}
594
595/// HMAC-SHA256(`salt`, `value`) → first 8 hex chars.
596///
597/// Pulled out as a free function so it can be unit-tested and benchmarked
598/// without constructing a full [`RbacPolicy`].
599fn 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    // HMAC-SHA256 accepts keys of any byte length: the spec pads short
606    // keys with zeros and hashes long keys, so `new_from_slice` is
607    // infallible here. We still defensively re-key with a SHA-256 of
608    // the salt if construction ever fails (e.g. future hmac upstream
609    // tightens the contract); both branches produce a valid keyed MAC.
610    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)] // 32-byte digest always valid as HMAC key
615        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    // 4 bytes → 8 hex chars.
620    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// -- RBAC middleware --
629
630/// Axum middleware that enforces RBAC and per-IP tool rate limiting on
631/// MCP tool calls.
632///
633/// Inspects POST request bodies for `tools/call` JSON-RPC messages,
634/// extracts the tool name and `host` argument, and checks the
635/// [`RbacPolicy`] against the [`AuthIdentity`] set by the auth middleware.
636///
637/// When a `tool_limiter` is provided, tool invocations are rate-limited
638/// per source IP regardless of whether RBAC is enabled (MCP spec: servers
639/// MUST rate limit tool invocations).
640///
641/// Non-POST requests and non-tool-call messages pass through unchanged.
642/// The caller's role is stored in task-local storage for use by tool
643/// handlers (e.g. `list_hosts` host filtering via [`current_role()`]).
644// TODO(refactor): cognitive complexity reduced from 43/25 by extracting
645// `enforce_tool_policy` and `enforce_rate_limit`. Remaining flow is a
646// linear body-collect + JSON-RPC parse + dispatch, intentionally left
647// inline to keep the request lifecycle visible at a glance.
648#[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    // Only inspect POST requests - tool calls are POSTs.
656    if req.method() != Method::POST {
657        return next.run(req).await;
658    }
659
660    // Extract peer IP for rate limiting.
661    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    // Extract caller identity and role (may be absent when auth is off).
672    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    // Clone the SecretString end-to-end; an absent token becomes an empty
676    // SecretString sentinel (current_token() filters this out as None).
677    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    // RBAC requires an authenticated identity.
683    if policy.is_enabled() && identity.is_none() {
684        return McpxError::Rbac("no authenticated identity".into()).into_response();
685    }
686
687    // Read the body for JSON-RPC inspection.
688    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    // Try to parse as JSON and inspect JSON-RPC tool calls, including batch arrays.
702    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    // Non-parseable or non-tool-call requests pass through.
718
719    // Reconstruct the request with the consumed body.
720    let req = Request::from_parts(parts, Body::from(bytes));
721
722    // Set the caller's role and identity in task-local storage for the handler.
723    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
738/// Extract the `params` object for every top-level `tools/call` message.
739///
740/// Supports either a single JSON-RPC object or a JSON-RPC batch array. Any
741/// malformed elements are ignored so non-RPC payloads continue to pass through
742/// unchanged.
743fn 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
774/// Per-IP rate limit check for tool invocations. Returns `Some(response)`
775/// if the caller should be rejected.
776fn 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
789/// Apply RBAC tool/host + argument-allowlist checks. Returns `Some(response)`
790/// when the caller must be rejected. Assumes `policy.is_enabled()`.
791///
792/// `identity_name` is passed explicitly (rather than read from
793/// [`current_identity()`]) because this function runs *before* the
794/// task-local context is installed by the middleware. Reading the
795/// task-local here would always yield `None`, producing deny logs with
796/// an empty `user` field.
797fn 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        // M2: an allowlist is configured for this argument but the
850        // caller sent a non-string JSON value (array/object/number/
851        // bool/null), which can never satisfy a `Vec<String>`
852        // allowlist. Fail closed; log the type (not the value) so
853        // operators see the rejected shape without leaking inputs.
854        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    // Redact the raw value: log an HMAC-SHA256 prefix instead of
873    // the literal string. Operators correlate hashes across log
874    // lines without ever exposing potentially sensitive inputs
875    // (paths, IDs, tokens accidentally passed as args, etc.).
876    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
903/// Simple glob matching: `*` matches any sequence of characters.
904///
905/// Supports multiple `*` wildcards anywhere in the pattern.
906/// No `?`, `[...]`, or other advanced glob features.
907fn glob_match(pattern: &str, text: &str) -> bool {
908    let parts: Vec<&str> = pattern.split('*').collect();
909    if parts.len() == 1 {
910        // No wildcards - exact match.
911        return pattern == text;
912    }
913
914    let mut pos = 0;
915
916    // First part must match at the start (unless pattern starts with *).
917    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    // Last part must match at the end (unless pattern ends with *).
927    if let Some(&last) = parts.last()
928        && !last.is_empty()
929    {
930        if !text[pos..].ends_with(last) {
931            return false;
932        }
933        // Shrink the search area so middle parts don't overlap with the suffix.
934        let end = text.len() - last.len();
935        if pos > end {
936            return false;
937        }
938        // Check middle parts in the remaining region.
939        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    // Pattern ends with * - just check middle parts.
945    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
950/// Match middle glob segments sequentially in `text`.
951fn 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    // -- glob_match tests --
1036
1037    #[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    // -- glob_match boundary / mutation-coverage tests --
1077    //
1078    // The cases below exist to kill specific mutants surfaced by
1079    // `cargo mutants` against `glob_match` / `match_middle` (see
1080    // CI run #84, May 2026). Each test is annotated with the mutation
1081    // it kills so the intent survives future refactors.
1082
1083    /// Kill: `if pos > end` mutated to `pos == end` and `pos >= end`
1084    /// at `glob_match` line 863. The prefix and suffix exactly meet
1085    /// (no characters between them); the original code accepts this,
1086    /// both mutants reject it.
1087    #[test]
1088    fn glob_prefix_and_suffix_meet_exactly() {
1089        // parts = ["ab", "cd"]; first.len()=2, end=text.len()-last.len()=2.
1090        // pos == end → original passes the `pos > end` check, mutants fail.
1091        assert!(glob_match("ab*cd", "abcd"));
1092    }
1093
1094    /// Kill: `parts.len() - 1` mutated to `parts.len() + 1` at line 868
1095    /// (middle-parts slice when pattern has a non-empty suffix). The
1096    /// mutant collapses the middle-parts slice to empty, which would
1097    /// incorrectly accept patterns whose middle segment isn't present.
1098    #[test]
1099    fn glob_middle_segment_required_with_suffix() {
1100        // Pattern requires "b" between "a" and "c"; text omits it.
1101        // Original: middle_parts=["b"], match_middle("xy", ["b"])=false → reject.
1102        // Mutant `+`: middle_parts=[] (slice out of bounds → unwrap_or_default),
1103        //             match_middle("xy", [])=true → wrongly accept.
1104        assert!(!glob_match("a*b*c", "axyc"));
1105    }
1106
1107    /// Kill: `idx + part.len()` mutated to `idx - part.len()` at
1108    /// `match_middle` line 885. The mutant either underflows
1109    /// (panic in test) or fails to advance past the matched part,
1110    /// causing it to re-find the same prefix and accept patterns
1111    /// that should be rejected.
1112    #[test]
1113    fn glob_match_middle_advances_past_matched_part() {
1114        // Original: after finding "ab" at idx 2, advance to text[4..]="_yz",
1115        //           which contains no second "ab" → reject.
1116        // Mutant `-`: text[2-2..]="xxab_yz" → re-finds "ab" → wrongly accept
1117        //             (or panics for the smaller-idx variants).
1118        assert!(!glob_match("*ab*ab*", "xxab_yz"));
1119    }
1120
1121    /// Kill: `idx + part.len()` mutated to `idx * part.len()` at
1122    /// `match_middle` line 885. The mutant computes a different
1123    /// (usually larger) advance offset that produces an out-of-bounds
1124    /// slice and panics, or skips over content that should match.
1125    #[test]
1126    fn glob_match_middle_uses_addition_not_multiplication() {
1127        // Original: find "abcde" at idx 8 in "yyyyyyyyabcde_X", advance
1128        //           to text[13..]="_X", find "X" → accept.
1129        // Mutant `*`: text[8*5..]=text[40..] → out-of-bounds → panic.
1130        assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1131    }
1132
1133    // -- RbacPolicy::argument_allowed mutation-coverage tests --
1134
1135    /// Kill: `&&` mutated to `||` at `argument_allowed` line 494.
1136    /// The original short-circuits the allowlist lookup only when both
1137    /// the literal name AND the glob fail to match. The mutant
1138    /// short-circuits when EITHER fails, which means a glob-matched
1139    /// allowlist (literal mismatch, glob match) is silently skipped
1140    /// and the call is wrongly allowed.
1141    #[test]
1142    fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1143        // Allowlist registered against pattern "run-*" with allowed=["ls"].
1144        // Calling tool="run-foo" — literal "run-*" != "run-foo" (true),
1145        // but glob_match("run-*", "run-foo") = true.
1146        //   Original `&&`: skip-condition = true && false = false → enforce
1147        //                  allowlist → "rm" not in ["ls"] → deny.
1148        //   Mutant `||`:   skip-condition = true || false = true → skip
1149        //                  allowlist → wrongly allow.
1150        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    // -- RbacPolicy::check tests --
1163
1164    #[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    // -- host_visible tests --
1261
1262    #[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    // -- argument_allowed tests --
1279
1280    #[test]
1281    fn argument_allowed_no_allowlist() {
1282        let policy = test_policy();
1283        // ops has no argument_allowlists -- all values allowed
1284        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    // -- shlex-tokenization regression tests (1.4.1) --
1337    //
1338    // These tests pin the POSIX-shell-like tokenization contract added
1339    // in 1.4.1. See `RbacPolicy::argument_allowed` doc comment for the
1340    // full contract; see CHANGELOG.md `[1.4.1]` for the behavior matrix.
1341
1342    /// Helper: build a minimal enabled policy with a single argument
1343    /// allowlist on tool `run`, argument `cmd`.
1344    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    // Per Oracle review: the next four tests pin the cases the original
1395    // handoff missed. Each confirms the *new* (1.4.1) deny behavior so a
1396    // future regression to the old `split_whitespace` semantics would
1397    // surface as a test failure.
1398
1399    #[test]
1400    fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1401        // value r#""""# parses to Some(vec![""]). An empty argv element
1402        // is never a runnable executable; deny even when "" is
1403        // explicitly allowlisted.
1404        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        // 1.4.0 behavior: split_whitespace first token = "'bash'" --
1411        //                 matched literal allowlist entry "'bash'".
1412        // 1.4.1 behavior: shlex strips the surrounding quotes -> first
1413        //                 token = "bash" -- no match against allowlist
1414        //                 entry "'bash'". Deny.
1415        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        // 1.4.0 behavior: literal first token "foo\\bar" matched.
1422        // 1.4.1 behavior: POSIX shlex treats backslash as escape ->
1423        //                 first token = "foobar". Allowlist entry with
1424        //                 a literal backslash no longer matches. Deny.
1425        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        // 1.4.0 behavior: literal Windows path matched.
1432        // 1.4.1 behavior: POSIX shlex eats backslashes -> path identity
1433        //                 changes; allowlist entry no longer matches.
1434        //                 Deny. Documented in CHANGELOG operator notes.
1435        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    // -- host_patterns tests --
1445
1446    #[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    // -- check_operation tests (no host check) --
1461
1462    #[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        // but check() with a non-matching host denies
1470        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    // -- current_role / current_identity tests --
1508
1509    #[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    // -- rbac_middleware integration tests --
1520
1521    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        // GET passes through even without identity.
1569        let req = Request::builder()
1570            .method(Method::GET)
1571            .uri("/mcp")
1572            .body(Body::empty())
1573            .unwrap();
1574        // GET on a POST-only route returns 405, but the middleware itself
1575        // doesn't block it -- it returns next.run(req).
1576        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        // A non-tools/call JSON-RPC (e.g. resources/list) passes through.
1651        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        // Allowed command
1678        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        // Denied command
1692        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        // No identity, disabled policy -- should pass.
1711        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    // -- redact_arg / redaction_salt tests --
1836
1837    #[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        // Hash collisions on 32 bits are 1-in-4-billion; safe to assert.
1867        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        // Within one process, the lazy OnceLock salt is shared.
1896        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        // Sanity floor: a single redaction should take well under 100 µs
1906        // even in unoptimized debug builds. Production criterion bench
1907        // (see H-T4 plan) will assert a stricter <10 µs threshold.
1908        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    // -- enforce_tool_policy identity propagation regression test (BUG H-S3) --
1920
1921    /// Regression: when `enforce_tool_policy` denied a request, the deny
1922    /// log used to read `current_identity()`, which was always `None` at
1923    /// that point because the task-local context is installed *after*
1924    /// policy enforcement. The fix passes `identity_name` explicitly.
1925    ///
1926    /// We assert the deny path returns 403 (the visible behaviour).
1927    /// The log-content assertion lives behind tracing-test which we have
1928    /// not yet added as a dev-dep; the explicit-parameter signature alone
1929    /// makes the previous bug structurally impossible.
1930    #[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        // viewer is not allowed to call resource_delete -> 403.
1942        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    // -- M2 regression: non-string argument values bypass allowlist --
1954
1955    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        // ops has no argument_allowlist for any (tool, arg) tuple, so
2067        // non-string values must reach the handler. resource_exec is in
2068        // ops's allow list so the call should not be rejected by RBAC.
2069        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}