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, KeyInit, Mac};
21use http_body_util::BodyExt;
22use secrecy::{ExposeSecret, SecretString};
23use serde::Deserialize;
24use sha2::Sha256;
25
26use crate::{
27    auth::{AuthIdentity, TlsConnInfo},
28    bounded_limiter::BoundedKeyedLimiter,
29    error::McpxError,
30};
31
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//
225// NOTE(future-pr): typed pre-tokenized argument matcher (CHANGELOG.md
226// "future release" promise).
227// Scope (Oracle-approved, internal-only, patch-safe):
228//   - Keep `ArgumentAllowlist` public shape UNCHANGED (wire/config stability).
229//   - In `RbacPolicy::new`, compile each allowlist once into a private
230//     `CompiledArgumentAllowlist` IR:
231//       * pre-resolve the `tool` selector: exact vs glob.
232//       * pre-tokenize first-token allowlists.
233//       * pre-tokenize basename allowlists.
234//   - At request time (`has_argument_allowlist` / `argument_allowed`),
235//     `shlex::split` each constrained argument once, then lookup in the
236//     compiled IR.
237//   - Required equivalence test matrix: exact tool names, globbed tool
238//     names, basename matches, quoted paths, fail-closed parse errors.
239//   - Profile before merge; justify by maintainability if perf delta <5%.
240#[derive(Debug, Clone, Deserialize)]
241#[non_exhaustive]
242pub struct ArgumentAllowlist {
243    /// Tool name to match (exact or glob, e.g. `"run_query"`).
244    pub tool: String,
245    /// Argument key whose value is checked (e.g. `"cmd"`, `"query"`).
246    pub argument: String,
247    /// Permitted first-token values. Empty means unrestricted.
248    #[serde(default)]
249    pub allowed: Vec<String>,
250}
251
252impl ArgumentAllowlist {
253    /// Create an argument allowlist for a tool.
254    #[must_use]
255    pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
256        Self {
257            tool: tool.into(),
258            argument: argument.into(),
259            allowed,
260        }
261    }
262}
263
264fn default_hosts() -> Vec<String> {
265    vec!["*".into()]
266}
267
268/// Top-level RBAC configuration (deserializable from TOML).
269#[derive(Debug, Clone, Default, Deserialize)]
270#[non_exhaustive]
271pub struct RbacConfig {
272    /// Master switch -- when false, the RBAC middleware is not installed.
273    #[serde(default)]
274    pub enabled: bool,
275    /// Role definitions available to identities.
276    #[serde(default)]
277    pub roles: Vec<RoleConfig>,
278    /// Optional stable HMAC key (any length) used to redact argument
279    /// values in deny logs. When set, redacted hashes are stable across
280    /// process restarts (useful for log correlation across deploys).
281    /// When `None`, a random 32-byte key is generated per process at
282    /// first use; redacted hashes change every restart.
283    ///
284    /// The key is wrapped in [`SecretString`] so it never leaks via
285    /// `Debug`/`Display`/serde and is zeroized on drop.
286    #[serde(default)]
287    pub redaction_salt: Option<SecretString>,
288}
289
290impl RbacConfig {
291    /// Create an enabled RBAC config with the given roles.
292    #[must_use]
293    pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
294        Self {
295            enabled: true,
296            roles,
297            redaction_salt: None,
298        }
299    }
300}
301
302/// Result of an RBAC policy check.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304#[non_exhaustive]
305pub enum RbacDecision {
306    /// Caller is permitted to perform the requested operation.
307    Allow,
308    /// Caller is denied access.
309    Deny,
310}
311
312/// Summary of a single role, produced by [`RbacPolicy::summary`].
313#[derive(Debug, Clone, serde::Serialize)]
314#[non_exhaustive]
315pub struct RbacRoleSummary {
316    /// Role name.
317    pub name: String,
318    /// Number of allow entries.
319    pub allow: usize,
320    /// Number of deny entries.
321    pub deny: usize,
322    /// Number of host patterns.
323    pub hosts: usize,
324    /// Number of argument allowlist entries.
325    pub argument_allowlists: usize,
326}
327
328/// Summary of the whole RBAC policy, produced by [`RbacPolicy::summary`].
329#[derive(Debug, Clone, serde::Serialize)]
330#[non_exhaustive]
331pub struct RbacPolicySummary {
332    /// Whether RBAC enforcement is active.
333    pub enabled: bool,
334    /// Per-role summaries.
335    pub roles: Vec<RbacRoleSummary>,
336}
337
338/// Compiled RBAC policy for fast lookup.
339///
340/// Built from [`RbacConfig`] at startup.  All lookups are O(n) over the
341/// role's allow/deny/host lists, which is fine for the expected cardinality
342/// (a handful of roles with tens of entries each).
343#[derive(Debug, Clone)]
344#[non_exhaustive]
345pub struct RbacPolicy {
346    roles: Vec<RoleConfig>,
347    enabled: bool,
348    /// HMAC key used to redact argument values in deny logs.
349    /// Either a configured stable salt or a per-process random salt.
350    redaction_salt: Arc<SecretString>,
351}
352
353impl RbacPolicy {
354    /// Build a policy from config.  When `config.enabled` is false, all
355    /// checks return [`RbacDecision::Allow`].
356    #[must_use]
357    pub fn new(config: &RbacConfig) -> Self {
358        let salt = config
359            .redaction_salt
360            .clone()
361            .unwrap_or_else(|| process_redaction_salt().clone());
362        Self {
363            roles: config.roles.clone(),
364            enabled: config.enabled,
365            redaction_salt: Arc::new(salt),
366        }
367    }
368
369    /// Create a policy that always allows (RBAC disabled).
370    #[must_use]
371    pub fn disabled() -> Self {
372        Self {
373            roles: Vec::new(),
374            enabled: false,
375            redaction_salt: Arc::new(process_redaction_salt().clone()),
376        }
377    }
378
379    /// Whether RBAC enforcement is active.
380    #[must_use]
381    pub fn is_enabled(&self) -> bool {
382        self.enabled
383    }
384
385    /// Summarize the policy for diagnostics (admin endpoint).
386    ///
387    /// Returns `(enabled, role_count, per_role_stats)` where each stat is
388    /// `(name, allow_count, deny_count, host_count, argument_allowlist_count)`.
389    #[must_use]
390    pub fn summary(&self) -> RbacPolicySummary {
391        let roles = self
392            .roles
393            .iter()
394            .map(|r| RbacRoleSummary {
395                name: r.name.clone(),
396                allow: r.allow.len(),
397                deny: r.deny.len(),
398                hosts: r.hosts.len(),
399                argument_allowlists: r.argument_allowlists.len(),
400            })
401            .collect();
402        RbacPolicySummary {
403            enabled: self.enabled,
404            roles,
405        }
406    }
407
408    /// Check whether `role` may perform `operation` (ignoring host).
409    ///
410    /// Use this for tools that don't target a specific host (e.g. `ping`,
411    /// `list_hosts`).
412    #[must_use]
413    pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
414        if !self.enabled {
415            return RbacDecision::Allow;
416        }
417        let Some(role_cfg) = self.find_role(role) else {
418            return RbacDecision::Deny;
419        };
420        if role_cfg.deny.iter().any(|d| d == operation) {
421            return RbacDecision::Deny;
422        }
423        if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
424            return RbacDecision::Allow;
425        }
426        RbacDecision::Deny
427    }
428
429    /// Check whether `role` may perform `operation` on `host`.
430    ///
431    /// Evaluation order:
432    /// 1. If RBAC is disabled, allow.
433    /// 2. Check operation permission (deny overrides allow).
434    /// 3. Check host visibility via glob matching.
435    #[must_use]
436    pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
437        if !self.enabled {
438            return RbacDecision::Allow;
439        }
440        let Some(role_cfg) = self.find_role(role) else {
441            return RbacDecision::Deny;
442        };
443        if role_cfg.deny.iter().any(|d| d == operation) {
444            return RbacDecision::Deny;
445        }
446        if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
447            return RbacDecision::Deny;
448        }
449        if !Self::host_matches(&role_cfg.hosts, host) {
450            return RbacDecision::Deny;
451        }
452        RbacDecision::Allow
453    }
454
455    /// Check whether `role` can see `host` at all (for `list_hosts` filtering).
456    #[must_use]
457    pub fn host_visible(&self, role: &str, host: &str) -> bool {
458        if !self.enabled {
459            return true;
460        }
461        let Some(role_cfg) = self.find_role(role) else {
462            return false;
463        };
464        Self::host_matches(&role_cfg.hosts, host)
465    }
466
467    /// Get the list of hosts patterns for a role.
468    #[must_use]
469    pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
470        self.find_role(role).map(|r| r.hosts.as_slice())
471    }
472
473    /// Check whether `value` passes the argument allowlists for `tool` under `role`.
474    ///
475    /// If the role has no matching `argument_allowlists` entry for the tool,
476    /// all values are allowed. When a matching entry exists, `value` is
477    /// tokenized using POSIX-shell-like lexical rules ([`shlex::split`])
478    /// and its first argv element (or the `/`-basename of that element)
479    /// must appear in the `allowed` list.
480    ///
481    /// **Scope of the contract.** This matcher targets consumers that
482    /// interpret string arguments as POSIX-shell-like command lines on
483    /// Unix-like systems (e.g. anything that subsequently feeds the value
484    /// through `shlex` or an equivalent splitter before `execve`). It
485    /// does **not** model real shell *execution* grammar (`FOO=1 cmd`,
486    /// expansion, command substitution, redirection, operators) or
487    /// Windows command-line tokenization (`CommandLineToArgvW`,
488    /// `cmd.exe`, PowerShell). Consumers in those regimes remain subject
489    /// to a parser differential and must validate at their own boundary.
490    ///
491    /// **Fail-closed cases (all return `false` when a matching allowlist
492    /// entry exists):**
493    ///
494    /// - `value` fails to parse as a POSIX-shell-like command line
495    ///   (e.g. unbalanced quotes, dangling escape).
496    /// - `value` parses to zero tokens (empty input).
497    /// - The first parsed token is the empty string (e.g.
498    ///   `value = r#""""#` parses to `Some(vec![""])`). An empty argv
499    ///   element is never a runnable executable, so we reject even when
500    ///   `""` is in the allowlist.
501    #[must_use]
502    pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
503        if !self.enabled {
504            return true;
505        }
506        let Some(role_cfg) = self.find_role(role) else {
507            return false;
508        };
509        for al in &role_cfg.argument_allowlists {
510            if al.tool != tool && !glob_match(&al.tool, tool) {
511                continue;
512            }
513            if al.argument != argument {
514                continue;
515            }
516            if al.allowed.is_empty() {
517                continue;
518            }
519            // Tokenize per POSIX-shell-like rules so quoted paths with
520            // spaces match what an equivalently-tokenizing consumer
521            // would actually run, and malformed shell syntax (unbalanced
522            // quotes, dangling escapes) fails closed.
523            let Some(tokens) = shlex::split(value) else {
524                return false;
525            };
526            let Some(first_token) = tokens.first() else {
527                return false;
528            };
529            // A well-formed but empty first argv element (e.g.
530            // value = r#""""#) is never a runnable executable. Fail
531            // closed even if "" appears in the allowlist.
532            if first_token.is_empty() {
533                return false;
534            }
535            // Also match against the basename if it's a path. POSIX
536            // separator only; Windows-style backslash paths are out of
537            // scope and will not basename-match (see crate-level docs).
538            let basename = first_token
539                .rsplit('/')
540                .next()
541                .unwrap_or(first_token.as_str());
542            if !al.allowed.iter().any(|a| a == first_token || a == basename) {
543                return false;
544            }
545        }
546        true
547    }
548
549    /// Return `true` if `(role, tool, argument)` has any non-empty
550    /// allowlist entry configured.
551    ///
552    /// Used by the tools/call middleware to decide whether non-string
553    /// JSON values must be rejected (M2 fix). When this returns `true`,
554    /// the value at `argument` must be a JSON string and pass
555    /// [`Self::argument_allowed`]; otherwise the call is denied with
556    /// 403. When this returns `false`, the value is unconstrained by
557    /// allowlist policy.
558    #[must_use]
559    pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
560        if !self.enabled {
561            return false;
562        }
563        let Some(role_cfg) = self.find_role(role) else {
564            return false;
565        };
566        role_cfg.argument_allowlists.iter().any(|al| {
567            (al.tool == tool || glob_match(&al.tool, tool))
568                && al.argument == argument
569                && !al.allowed.is_empty()
570        })
571    }
572
573    /// Return the role config for a given role name.
574    fn find_role(&self, name: &str) -> Option<&RoleConfig> {
575        self.roles.iter().find(|r| r.name == name)
576    }
577
578    /// Check if a host name matches any of the given glob patterns.
579    fn host_matches(patterns: &[String], host: &str) -> bool {
580        patterns.iter().any(|p| glob_match(p, host))
581    }
582
583    /// HMAC-SHA256 the given argument value with this policy's redaction
584    /// salt and return the first 8 hex characters (4 bytes / 32 bits).
585    ///
586    /// 32 bits is enough entropy for log correlation (1-in-4-billion
587    /// collision per pair) while being far short of any preimage attack
588    /// surface for an attacker reading logs. The HMAC construction
589    /// guarantees that even short or low-entropy values cannot be
590    /// recovered without the key.
591    #[must_use]
592    pub fn redact_arg(&self, value: &str) -> String {
593        redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
594    }
595}
596
597/// Process-wide random redaction salt, lazily generated on first use.
598/// Used when [`RbacConfig::redaction_salt`] is `None`.
599fn process_redaction_salt() -> &'static SecretString {
600    use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
601    static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
602    PROCESS_SALT.get_or_init(|| {
603        let mut bytes = [0u8; 32];
604        rand::fill(&mut bytes);
605        // base64-encode so the SecretString is valid UTF-8; the HMAC
606        // accepts arbitrary key bytes regardless.
607        SecretString::from(STANDARD_NO_PAD.encode(bytes))
608    })
609}
610
611/// HMAC-SHA256(`salt`, `value`) → first 8 hex chars.
612///
613/// Pulled out as a free function so it can be unit-tested and benchmarked
614/// without constructing a full [`RbacPolicy`].
615fn redact_with_salt(salt: &[u8], value: &str) -> String {
616    use std::fmt::Write as _;
617
618    use sha2::Digest as _;
619
620    type HmacSha256 = Hmac<Sha256>;
621    // HMAC-SHA256 accepts keys of any byte length: the spec pads short
622    // keys with zeros and hashes long keys, so `new_from_slice` is
623    // infallible here. We still defensively re-key with a SHA-256 of
624    // the salt if construction ever fails (e.g. future hmac upstream
625    // tightens the contract); both branches produce a valid keyed MAC.
626    let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
627        m
628    } else {
629        let digest = Sha256::digest(salt);
630        #[allow(
631            clippy::expect_used,
632            reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
633        )]
634        HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
635    };
636    mac.update(value.as_bytes());
637    let bytes = mac.finalize().into_bytes();
638    // 4 bytes → 8 hex chars.
639    let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
640    let mut out = String::with_capacity(8);
641    for b in prefix {
642        let _ = write!(out, "{b:02x}");
643    }
644    out
645}
646
647// -- RBAC middleware --
648
649/// Axum middleware that enforces RBAC and per-IP tool rate limiting on
650/// MCP tool calls.
651///
652/// Inspects POST request bodies for `tools/call` JSON-RPC messages,
653/// extracts the tool name and `host` argument, and checks the
654/// [`RbacPolicy`] against the [`AuthIdentity`] set by the auth middleware.
655///
656/// When a `tool_limiter` is provided, tool invocations are rate-limited
657/// per source IP regardless of whether RBAC is enabled (MCP spec: servers
658/// MUST rate limit tool invocations).
659///
660/// Non-POST requests and non-tool-call messages pass through unchanged.
661/// The caller's role is stored in task-local storage for use by tool
662/// handlers (e.g. `list_hosts` host filtering via [`current_role()`]).
663// NOTE: cognitive complexity reduced from 43/25 by extracting
664// `enforce_tool_policy` and `enforce_rate_limit`. Remaining flow is a
665// linear body-collect + JSON-RPC parse + dispatch, intentionally left
666// inline to keep the request lifecycle visible at a glance.
667#[allow(
668    clippy::too_many_lines,
669    reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
670)]
671pub(crate) async fn rbac_middleware(
672    policy: Arc<RbacPolicy>,
673    tool_limiter: Option<Arc<ToolRateLimiter>>,
674    req: Request<Body>,
675    next: Next,
676) -> Response {
677    // Only inspect POST requests - tool calls are POSTs.
678    if req.method() != Method::POST {
679        return next.run(req).await;
680    }
681
682    // Extract peer IP for rate limiting.
683    let peer_ip: Option<IpAddr> = req
684        .extensions()
685        .get::<ConnectInfo<std::net::SocketAddr>>()
686        .map(|ci| ci.0.ip())
687        .or_else(|| {
688            req.extensions()
689                .get::<ConnectInfo<TlsConnInfo>>()
690                .map(|ci| ci.0.addr.ip())
691        });
692
693    // Extract caller identity and role (may be absent when auth is off).
694    let identity = req.extensions().get::<AuthIdentity>();
695    let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
696    let role = identity.map(|id| id.role.clone()).unwrap_or_default();
697    // Clone the SecretString end-to-end; an absent token becomes an empty
698    // SecretString sentinel (current_token() filters this out as None).
699    let raw_token: SecretString = identity
700        .and_then(|id| id.raw_token.clone())
701        .unwrap_or_else(|| SecretString::from(String::new()));
702    let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
703
704    // RBAC requires an authenticated identity.
705    if policy.is_enabled() && identity.is_none() {
706        return McpxError::Rbac("no authenticated identity".into()).into_response();
707    }
708
709    // Read the body for JSON-RPC inspection.
710    let (parts, body) = req.into_parts();
711    let bytes = match body.collect().await {
712        Ok(collected) => collected.to_bytes(),
713        Err(e) => {
714            tracing::error!(error = %e, "failed to read request body");
715            return (
716                StatusCode::INTERNAL_SERVER_ERROR,
717                "failed to read request body",
718            )
719                .into_response();
720        }
721    };
722
723    // Try to parse as JSON and inspect JSON-RPC tool calls, including batch arrays.
724    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
725        let tool_calls = extract_tool_calls(&json);
726        if !tool_calls.is_empty() {
727            for params in tool_calls {
728                if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
729                    return resp;
730                }
731                if policy.is_enabled()
732                    && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
733                {
734                    return resp;
735                }
736            }
737        }
738    }
739    // Non-parseable or non-tool-call requests pass through.
740
741    // Reconstruct the request with the consumed body.
742    let req = Request::from_parts(parts, Body::from(bytes));
743
744    // Set the caller's role and identity in task-local storage for the handler.
745    if role.is_empty() {
746        next.run(req).await
747    } else {
748        CURRENT_ROLE
749            .scope(
750                role,
751                CURRENT_IDENTITY.scope(
752                    identity_name,
753                    CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
754                ),
755            )
756            .await
757    }
758}
759
760/// Extract the `params` object for every top-level `tools/call` message.
761///
762/// Supports either a single JSON-RPC object or a JSON-RPC batch array. Any
763/// malformed elements are ignored so non-RPC payloads continue to pass through
764/// unchanged.
765fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
766    match value {
767        serde_json::Value::Object(map) => map
768            .get("method")
769            .and_then(serde_json::Value::as_str)
770            .filter(|method| *method == "tools/call")
771            .and_then(|_| map.get("params"))
772            .into_iter()
773            .collect(),
774        serde_json::Value::Array(items) => items
775            .iter()
776            .filter_map(|item| match item {
777                serde_json::Value::Object(map) => map
778                    .get("method")
779                    .and_then(serde_json::Value::as_str)
780                    .filter(|method| *method == "tools/call")
781                    .and_then(|_| map.get("params")),
782                serde_json::Value::Null
783                | serde_json::Value::Bool(_)
784                | serde_json::Value::Number(_)
785                | serde_json::Value::String(_)
786                | serde_json::Value::Array(_) => None,
787            })
788            .collect(),
789        serde_json::Value::Null
790        | serde_json::Value::Bool(_)
791        | serde_json::Value::Number(_)
792        | serde_json::Value::String(_) => Vec::new(),
793    }
794}
795
796/// Per-IP rate limit check for tool invocations. Returns `Some(response)`
797/// if the caller should be rejected.
798fn enforce_rate_limit(
799    tool_limiter: Option<&ToolRateLimiter>,
800    peer_ip: Option<IpAddr>,
801) -> Option<Response> {
802    let limiter = tool_limiter?;
803    let ip = peer_ip?;
804    if limiter.check_key(&ip).is_err() {
805        tracing::warn!(%ip, "tool invocation rate limited");
806        return Some(McpxError::RateLimited("too many tool invocations".into()).into_response());
807    }
808    None
809}
810
811/// Apply RBAC tool/host + argument-allowlist checks. Returns `Some(response)`
812/// when the caller must be rejected. Assumes `policy.is_enabled()`.
813///
814/// `identity_name` is passed explicitly (rather than read from
815/// [`current_identity()`]) because this function runs *before* the
816/// task-local context is installed by the middleware. Reading the
817/// task-local here would always yield `None`, producing deny logs with
818/// an empty `user` field.
819fn enforce_tool_policy(
820    policy: &RbacPolicy,
821    identity_name: &str,
822    role: &str,
823    params: &serde_json::Value,
824) -> Option<Response> {
825    let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
826    let host = params
827        .get("arguments")
828        .and_then(|a| a.get("host"))
829        .and_then(|h| h.as_str());
830
831    let decision = if let Some(host) = host {
832        policy.check(role, tool_name, host)
833    } else {
834        policy.check_operation(role, tool_name)
835    };
836    if decision == RbacDecision::Deny {
837        tracing::warn!(
838            user = %identity_name,
839            role = %role,
840            tool = tool_name,
841            host = host.unwrap_or("-"),
842            "RBAC denied"
843        );
844        return Some(
845            McpxError::Rbac(format!("{tool_name} denied for role '{role}'")).into_response(),
846        );
847    }
848
849    let args = params.get("arguments").and_then(|a| a.as_object())?;
850    for (arg_key, arg_val) in args {
851        if let Some(resp) = check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
852        {
853            return Some(resp);
854        }
855    }
856    None
857}
858
859fn check_argument(
860    policy: &RbacPolicy,
861    identity_name: &str,
862    role: &str,
863    tool_name: &str,
864    arg_key: &str,
865    arg_val: &serde_json::Value,
866) -> Option<Response> {
867    if !policy.has_argument_allowlist(role, tool_name, arg_key) {
868        return None;
869    }
870    let Some(val_str) = arg_val.as_str() else {
871        // M2: an allowlist is configured for this argument but the
872        // caller sent a non-string JSON value (array/object/number/
873        // bool/null), which can never satisfy a `Vec<String>`
874        // allowlist. Fail closed; log the type (not the value) so
875        // operators see the rejected shape without leaking inputs.
876        tracing::warn!(
877            user = %identity_name,
878            role = %role,
879            tool = tool_name,
880            argument = arg_key,
881            value_type = json_value_type(arg_val),
882            "non-string argument rejected by allowlist"
883        );
884        return Some(
885            McpxError::Rbac(format!(
886                "argument '{arg_key}' must be a string for tool '{tool_name}'"
887            ))
888            .into_response(),
889        );
890    };
891    if policy.argument_allowed(role, tool_name, arg_key, val_str) {
892        return None;
893    }
894    // Redact the raw value: log an HMAC-SHA256 prefix instead of
895    // the literal string. Operators correlate hashes across log
896    // lines without ever exposing potentially sensitive inputs
897    // (paths, IDs, tokens accidentally passed as args, etc.).
898    tracing::warn!(
899        user = %identity_name,
900        role = %role,
901        tool = tool_name,
902        argument = arg_key,
903        arg_hmac = %policy.redact_arg(val_str),
904        "argument not in allowlist"
905    );
906    Some(
907        McpxError::Rbac(format!(
908            "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
909        ))
910        .into_response(),
911    )
912}
913
914fn json_value_type(v: &serde_json::Value) -> &'static str {
915    match v {
916        serde_json::Value::Null => "null",
917        serde_json::Value::Bool(_) => "bool",
918        serde_json::Value::Number(_) => "number",
919        serde_json::Value::String(_) => "string",
920        serde_json::Value::Array(_) => "array",
921        serde_json::Value::Object(_) => "object",
922    }
923}
924
925/// Simple glob matching: `*` matches any sequence of characters.
926///
927/// Supports multiple `*` wildcards anywhere in the pattern.
928/// No `?`, `[...]`, or other advanced glob features.
929///
930/// All slice offsets are derived from `starts_with`/`ends_with`/`find`,
931/// which guarantee char-boundary alignment; the `get(..)` accessors keep
932/// that machine-checked (a violated invariant degrades to a non-match
933/// instead of a panic).
934fn glob_match(pattern: &str, text: &str) -> bool {
935    let parts: Vec<&str> = pattern.split('*').collect();
936    if parts.len() == 1 {
937        // No wildcards - exact match.
938        return pattern == text;
939    }
940
941    let mut pos = 0;
942
943    // First part must match at the start (unless pattern starts with *).
944    if let Some(&first) = parts.first()
945        && !first.is_empty()
946    {
947        if !text.starts_with(first) {
948            return false;
949        }
950        pos = first.len();
951    }
952
953    // Last part must match at the end (unless pattern ends with *).
954    if let Some(&last) = parts.last()
955        && !last.is_empty()
956    {
957        if !text.get(pos..).unwrap_or_default().ends_with(last) {
958            return false;
959        }
960        // Shrink the search area so middle parts don't overlap with the suffix.
961        let end = text.len() - last.len();
962        if pos > end {
963            return false;
964        }
965        // Check middle parts in the remaining region.
966        let middle = text.get(pos..end).unwrap_or_default();
967        let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
968        return match_middle(middle, middle_parts);
969    }
970
971    // Pattern ends with * - just check middle parts.
972    let middle = text.get(pos..).unwrap_or_default();
973    let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
974    match_middle(middle, middle_parts)
975}
976
977/// Match middle glob segments sequentially in `text`.
978fn match_middle(mut text: &str, parts: &[&str]) -> bool {
979    for part in parts {
980        if part.is_empty() {
981            continue;
982        }
983        if let Some(idx) = text.find(part) {
984            text = text.get(idx + part.len()..).unwrap_or_default();
985        } else {
986            return false;
987        }
988    }
989    true
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    fn test_policy() -> RbacPolicy {
997        RbacPolicy::new(&RbacConfig {
998            enabled: true,
999            roles: vec![
1000                RoleConfig {
1001                    name: "viewer".into(),
1002                    description: Some("Read-only".into()),
1003                    allow: vec![
1004                        "list_hosts".into(),
1005                        "resource_list".into(),
1006                        "resource_inspect".into(),
1007                        "resource_logs".into(),
1008                        "system_info".into(),
1009                    ],
1010                    deny: vec![],
1011                    hosts: vec!["*".into()],
1012                    argument_allowlists: vec![],
1013                },
1014                RoleConfig {
1015                    name: "deploy".into(),
1016                    description: Some("Lifecycle management".into()),
1017                    allow: vec![
1018                        "list_hosts".into(),
1019                        "resource_list".into(),
1020                        "resource_run".into(),
1021                        "resource_start".into(),
1022                        "resource_stop".into(),
1023                        "resource_restart".into(),
1024                        "resource_logs".into(),
1025                        "image_pull".into(),
1026                    ],
1027                    deny: vec!["resource_delete".into(), "resource_exec".into()],
1028                    hosts: vec!["web-*".into(), "api-*".into()],
1029                    argument_allowlists: vec![],
1030                },
1031                RoleConfig {
1032                    name: "ops".into(),
1033                    description: Some("Full access".into()),
1034                    allow: vec!["*".into()],
1035                    deny: vec![],
1036                    hosts: vec!["*".into()],
1037                    argument_allowlists: vec![],
1038                },
1039                RoleConfig {
1040                    name: "restricted-exec".into(),
1041                    description: Some("Exec with argument allowlist".into()),
1042                    allow: vec!["resource_exec".into()],
1043                    deny: vec![],
1044                    hosts: vec!["dev-*".into()],
1045                    argument_allowlists: vec![ArgumentAllowlist {
1046                        tool: "resource_exec".into(),
1047                        argument: "cmd".into(),
1048                        allowed: vec![
1049                            "sh".into(),
1050                            "bash".into(),
1051                            "cat".into(),
1052                            "ls".into(),
1053                            "ps".into(),
1054                        ],
1055                    }],
1056                },
1057            ],
1058            redaction_salt: None,
1059        })
1060    }
1061
1062    // -- glob_match tests --
1063
1064    #[test]
1065    fn glob_exact_match() {
1066        assert!(glob_match("web-prod-1", "web-prod-1"));
1067        assert!(!glob_match("web-prod-1", "web-prod-2"));
1068    }
1069
1070    #[test]
1071    fn glob_star_suffix() {
1072        assert!(glob_match("web-*", "web-prod-1"));
1073        assert!(glob_match("web-*", "web-staging"));
1074        assert!(!glob_match("web-*", "api-prod"));
1075    }
1076
1077    #[test]
1078    fn glob_star_prefix() {
1079        assert!(glob_match("*-prod", "web-prod"));
1080        assert!(glob_match("*-prod", "api-prod"));
1081        assert!(!glob_match("*-prod", "web-staging"));
1082    }
1083
1084    #[test]
1085    fn glob_star_middle() {
1086        assert!(glob_match("web-*-prod", "web-us-prod"));
1087        assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1088        assert!(!glob_match("web-*-prod", "web-staging"));
1089    }
1090
1091    #[test]
1092    fn glob_star_only() {
1093        assert!(glob_match("*", "anything"));
1094        assert!(glob_match("*", ""));
1095    }
1096
1097    #[test]
1098    fn glob_multiple_stars() {
1099        assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1100        assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1101    }
1102
1103    /// Pin char-boundary behavior of the `get(..)`-based slicing across
1104    /// multi-byte UTF-8 text: offsets derived from `starts_with` /
1105    /// `ends_with` / `find` are always boundary-aligned, and matching
1106    /// must behave identically to the ASCII cases.
1107    #[test]
1108    fn glob_match_multibyte_utf8() {
1109        assert!(glob_match("hé*llo", "héllo"));
1110        assert!(glob_match("*ö*", "wörld"));
1111        assert!(glob_match("über*", "übermensch"));
1112        assert!(glob_match("*界", "世界"));
1113        assert!(!glob_match("hé*llo", "hello"));
1114        assert!(!glob_match("界*", "世界"));
1115        assert!(glob_match("世*界", "世界"));
1116    }
1117
1118    // -- glob_match boundary / mutation-coverage tests --
1119    //
1120    // The cases below exist to kill specific mutants surfaced by
1121    // `cargo mutants` against `glob_match` / `match_middle` (see
1122    // CI run #84, May 2026). Each test is annotated with the mutation
1123    // it kills so the intent survives future refactors.
1124
1125    /// Kill: `if pos > end` mutated to `pos == end` and `pos >= end`
1126    /// at `glob_match` line 863. The prefix and suffix exactly meet
1127    /// (no characters between them); the original code accepts this,
1128    /// both mutants reject it.
1129    #[test]
1130    fn glob_prefix_and_suffix_meet_exactly() {
1131        // parts = ["ab", "cd"]; first.len()=2, end=text.len()-last.len()=2.
1132        // pos == end → original passes the `pos > end` check, mutants fail.
1133        assert!(glob_match("ab*cd", "abcd"));
1134    }
1135
1136    /// Kill: `parts.len() - 1` mutated to `parts.len() + 1` at line 868
1137    /// (middle-parts slice when pattern has a non-empty suffix). The
1138    /// mutant collapses the middle-parts slice to empty, which would
1139    /// incorrectly accept patterns whose middle segment isn't present.
1140    #[test]
1141    fn glob_middle_segment_required_with_suffix() {
1142        // Pattern requires "b" between "a" and "c"; text omits it.
1143        // Original: middle_parts=["b"], match_middle("xy", ["b"])=false → reject.
1144        // Mutant `+`: middle_parts=[] (slice out of bounds → unwrap_or_default),
1145        //             match_middle("xy", [])=true → wrongly accept.
1146        assert!(!glob_match("a*b*c", "axyc"));
1147    }
1148
1149    /// Kill: `idx + part.len()` mutated to `idx - part.len()` at
1150    /// `match_middle` line 885. The mutant either underflows
1151    /// (panic in test) or fails to advance past the matched part,
1152    /// causing it to re-find the same prefix and accept patterns
1153    /// that should be rejected.
1154    #[test]
1155    fn glob_match_middle_advances_past_matched_part() {
1156        // Original: after finding "ab" at idx 2, advance to text[4..]="_yz",
1157        //           which contains no second "ab" → reject.
1158        // Mutant `-`: text[2-2..]="xxab_yz" → re-finds "ab" → wrongly accept
1159        //             (or panics for the smaller-idx variants).
1160        assert!(!glob_match("*ab*ab*", "xxab_yz"));
1161    }
1162
1163    /// Kill: `idx + part.len()` mutated to `idx * part.len()` at
1164    /// `match_middle` line 885. The mutant computes a different
1165    /// (usually larger) advance offset that produces an out-of-bounds
1166    /// slice and panics, or skips over content that should match.
1167    #[test]
1168    fn glob_match_middle_uses_addition_not_multiplication() {
1169        // Original: find "abcde" at idx 8 in "yyyyyyyyabcde_X", advance
1170        //           to text[13..]="_X", find "X" → accept.
1171        // Mutant `*`: text[8*5..]=text[40..] → out-of-bounds → panic.
1172        assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1173    }
1174
1175    // -- RbacPolicy::argument_allowed mutation-coverage tests --
1176
1177    /// Kill: `&&` mutated to `||` at `argument_allowed` line 494.
1178    /// The original short-circuits the allowlist lookup only when both
1179    /// the literal name AND the glob fail to match. The mutant
1180    /// short-circuits when EITHER fails, which means a glob-matched
1181    /// allowlist (literal mismatch, glob match) is silently skipped
1182    /// and the call is wrongly allowed.
1183    #[test]
1184    fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1185        // Allowlist registered against pattern "run-*" with allowed=["ls"].
1186        // Calling tool="run-foo" — literal "run-*" != "run-foo" (true),
1187        // but glob_match("run-*", "run-foo") = true.
1188        //   Original `&&`: skip-condition = true && false = false → enforce
1189        //                  allowlist → "rm" not in ["ls"] → deny.
1190        //   Mutant `||`:   skip-condition = true || false = true → skip
1191        //                  allowlist → wrongly allow.
1192        let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1193            .with_argument_allowlists(vec![ArgumentAllowlist::new(
1194                "run-*",
1195                "cmd",
1196                vec!["ls".into()],
1197            )]);
1198        let mut config = RbacConfig::with_roles(vec![role]);
1199        config.enabled = true;
1200        let policy = RbacPolicy::new(&config);
1201        assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1202    }
1203
1204    // -- RbacPolicy::check tests --
1205
1206    #[test]
1207    fn disabled_policy_allows_everything() {
1208        let policy = RbacPolicy::new(&RbacConfig {
1209            enabled: false,
1210            roles: vec![],
1211            redaction_salt: None,
1212        });
1213        assert_eq!(
1214            policy.check("nonexistent", "resource_delete", "any-host"),
1215            RbacDecision::Allow
1216        );
1217    }
1218
1219    #[test]
1220    fn unknown_role_denied() {
1221        let policy = test_policy();
1222        assert_eq!(
1223            policy.check("unknown", "resource_list", "web-prod-1"),
1224            RbacDecision::Deny
1225        );
1226    }
1227
1228    #[test]
1229    fn viewer_allowed_read_ops() {
1230        let policy = test_policy();
1231        assert_eq!(
1232            policy.check("viewer", "resource_list", "web-prod-1"),
1233            RbacDecision::Allow
1234        );
1235        assert_eq!(
1236            policy.check("viewer", "system_info", "db-host"),
1237            RbacDecision::Allow
1238        );
1239    }
1240
1241    #[test]
1242    fn viewer_denied_write_ops() {
1243        let policy = test_policy();
1244        assert_eq!(
1245            policy.check("viewer", "resource_run", "web-prod-1"),
1246            RbacDecision::Deny
1247        );
1248        assert_eq!(
1249            policy.check("viewer", "resource_delete", "web-prod-1"),
1250            RbacDecision::Deny
1251        );
1252    }
1253
1254    #[test]
1255    fn deploy_allowed_on_matching_hosts() {
1256        let policy = test_policy();
1257        assert_eq!(
1258            policy.check("deploy", "resource_run", "web-prod-1"),
1259            RbacDecision::Allow
1260        );
1261        assert_eq!(
1262            policy.check("deploy", "resource_start", "api-staging"),
1263            RbacDecision::Allow
1264        );
1265    }
1266
1267    #[test]
1268    fn deploy_denied_on_non_matching_host() {
1269        let policy = test_policy();
1270        assert_eq!(
1271            policy.check("deploy", "resource_run", "db-prod-1"),
1272            RbacDecision::Deny
1273        );
1274    }
1275
1276    #[test]
1277    fn deny_overrides_allow() {
1278        let policy = test_policy();
1279        assert_eq!(
1280            policy.check("deploy", "resource_delete", "web-prod-1"),
1281            RbacDecision::Deny
1282        );
1283        assert_eq!(
1284            policy.check("deploy", "resource_exec", "web-prod-1"),
1285            RbacDecision::Deny
1286        );
1287    }
1288
1289    #[test]
1290    fn ops_wildcard_allows_everything() {
1291        let policy = test_policy();
1292        assert_eq!(
1293            policy.check("ops", "resource_delete", "any-host"),
1294            RbacDecision::Allow
1295        );
1296        assert_eq!(
1297            policy.check("ops", "secret_create", "db-host"),
1298            RbacDecision::Allow
1299        );
1300    }
1301
1302    // -- host_visible tests --
1303
1304    #[test]
1305    fn host_visible_respects_globs() {
1306        let policy = test_policy();
1307        assert!(policy.host_visible("deploy", "web-prod-1"));
1308        assert!(policy.host_visible("deploy", "api-staging"));
1309        assert!(!policy.host_visible("deploy", "db-prod-1"));
1310        assert!(policy.host_visible("ops", "anything"));
1311        assert!(policy.host_visible("viewer", "anything"));
1312    }
1313
1314    #[test]
1315    fn host_visible_unknown_role() {
1316        let policy = test_policy();
1317        assert!(!policy.host_visible("unknown", "web-prod-1"));
1318    }
1319
1320    // -- argument_allowed tests --
1321
1322    #[test]
1323    fn argument_allowed_no_allowlist() {
1324        let policy = test_policy();
1325        // ops has no argument_allowlists -- all values allowed
1326        assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1327        assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1328    }
1329
1330    #[test]
1331    fn argument_allowed_with_allowlist() {
1332        let policy = test_policy();
1333        assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1334        assert!(policy.argument_allowed(
1335            "restricted-exec",
1336            "resource_exec",
1337            "cmd",
1338            "bash -c 'echo hi'"
1339        ));
1340        assert!(policy.argument_allowed(
1341            "restricted-exec",
1342            "resource_exec",
1343            "cmd",
1344            "cat /etc/hosts"
1345        ));
1346        assert!(policy.argument_allowed(
1347            "restricted-exec",
1348            "resource_exec",
1349            "cmd",
1350            "/usr/bin/ls -la"
1351        ));
1352    }
1353
1354    #[test]
1355    fn argument_denied_not_in_allowlist() {
1356        let policy = test_policy();
1357        assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1358        assert!(!policy.argument_allowed(
1359            "restricted-exec",
1360            "resource_exec",
1361            "cmd",
1362            "python3 exploit.py"
1363        ));
1364        assert!(!policy.argument_allowed(
1365            "restricted-exec",
1366            "resource_exec",
1367            "cmd",
1368            "/usr/bin/curl evil.com"
1369        ));
1370    }
1371
1372    #[test]
1373    fn argument_denied_unknown_role() {
1374        let policy = test_policy();
1375        assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1376    }
1377
1378    // -- shlex-tokenization regression tests (1.4.1) --
1379    //
1380    // These tests pin the POSIX-shell-like tokenization contract added
1381    // in 1.4.1. See `RbacPolicy::argument_allowed` doc comment for the
1382    // full contract; see CHANGELOG.md `[1.4.1]` for the behavior matrix.
1383
1384    /// Helper: build a minimal enabled policy with a single argument
1385    /// allowlist on tool `run`, argument `cmd`.
1386    fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1387        let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1388            .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1389        let mut config = RbacConfig::with_roles(vec![role]);
1390        config.enabled = true;
1391        RbacPolicy::new(&config)
1392    }
1393
1394    #[test]
1395    fn argument_allowed_matches_quoted_path_with_spaces() {
1396        let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1397        assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1398    }
1399
1400    #[test]
1401    fn argument_allowed_matches_basename_of_quoted_path() {
1402        let policy = shlex_policy(vec!["my tool".into()]);
1403        assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1404    }
1405
1406    #[test]
1407    fn argument_allowed_fails_closed_on_unbalanced_quote() {
1408        let policy = shlex_policy(vec!["unbalanced".into()]);
1409        assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1410    }
1411
1412    #[test]
1413    fn argument_allowed_fails_closed_on_empty_string() {
1414        let policy = shlex_policy(vec![String::new()]);
1415        assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1416    }
1417
1418    #[test]
1419    fn argument_allowed_handles_single_quoted_executable() {
1420        let policy = shlex_policy(vec!["/bin/sh".into()]);
1421        assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1422    }
1423
1424    #[test]
1425    fn argument_allowed_handles_tab_separator() {
1426        let policy = shlex_policy(vec!["ls".into()]);
1427        assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1428    }
1429
1430    #[test]
1431    fn argument_allowed_plain_token_unchanged() {
1432        let policy = shlex_policy(vec!["ls".into()]);
1433        assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1434    }
1435
1436    // Per Oracle review: the next four tests pin the cases the original
1437    // handoff missed. Each confirms the *new* (1.4.1) deny behavior so a
1438    // future regression to the old `split_whitespace` semantics would
1439    // surface as a test failure.
1440
1441    #[test]
1442    fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1443        // value r#""""# parses to Some(vec![""]). An empty argv element
1444        // is never a runnable executable; deny even when "" is
1445        // explicitly allowlisted.
1446        let policy = shlex_policy(vec![String::new()]);
1447        assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1448    }
1449
1450    #[test]
1451    fn argument_allowed_quoted_literal_token_no_longer_matches() {
1452        // 1.4.0 behavior: split_whitespace first token = "'bash'" --
1453        //                 matched literal allowlist entry "'bash'".
1454        // 1.4.1 behavior: shlex strips the surrounding quotes -> first
1455        //                 token = "bash" -- no match against allowlist
1456        //                 entry "'bash'". Deny.
1457        let policy = shlex_policy(vec!["'bash'".into()]);
1458        assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1459    }
1460
1461    #[test]
1462    fn argument_allowed_backslash_literal_token_no_longer_matches() {
1463        // 1.4.0 behavior: literal first token "foo\\bar" matched.
1464        // 1.4.1 behavior: POSIX shlex treats backslash as escape ->
1465        //                 first token = "foobar". Allowlist entry with
1466        //                 a literal backslash no longer matches. Deny.
1467        let policy = shlex_policy(vec![r"foo\bar".into()]);
1468        assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1469    }
1470
1471    #[test]
1472    fn argument_allowed_windows_path_no_longer_matches() {
1473        // 1.4.0 behavior: literal Windows path matched.
1474        // 1.4.1 behavior: POSIX shlex eats backslashes -> path identity
1475        //                 changes; allowlist entry no longer matches.
1476        //                 Deny. Documented in CHANGELOG operator notes.
1477        let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1478        assert!(!policy.argument_allowed(
1479            "viewer",
1480            "run",
1481            "cmd",
1482            r"C:\Windows\System32\cmd.exe /c dir"
1483        ));
1484    }
1485
1486    // -- host_patterns tests --
1487
1488    #[test]
1489    fn host_patterns_returns_globs() {
1490        let policy = test_policy();
1491        assert_eq!(
1492            policy.host_patterns("deploy"),
1493            Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1494        );
1495        assert_eq!(
1496            policy.host_patterns("ops"),
1497            Some(vec!["*".to_owned()].as_slice())
1498        );
1499        assert!(policy.host_patterns("nonexistent").is_none());
1500    }
1501
1502    // -- check_operation tests (no host check) --
1503
1504    #[test]
1505    fn check_operation_allows_without_host() {
1506        let policy = test_policy();
1507        assert_eq!(
1508            policy.check_operation("deploy", "resource_run"),
1509            RbacDecision::Allow
1510        );
1511        // but check() with a non-matching host denies
1512        assert_eq!(
1513            policy.check("deploy", "resource_run", "db-prod-1"),
1514            RbacDecision::Deny
1515        );
1516    }
1517
1518    #[test]
1519    fn check_operation_deny_overrides() {
1520        let policy = test_policy();
1521        assert_eq!(
1522            policy.check_operation("deploy", "resource_delete"),
1523            RbacDecision::Deny
1524        );
1525    }
1526
1527    #[test]
1528    fn check_operation_unknown_role() {
1529        let policy = test_policy();
1530        assert_eq!(
1531            policy.check_operation("unknown", "resource_list"),
1532            RbacDecision::Deny
1533        );
1534    }
1535
1536    #[test]
1537    fn check_operation_disabled() {
1538        let policy = RbacPolicy::new(&RbacConfig {
1539            enabled: false,
1540            roles: vec![],
1541            redaction_salt: None,
1542        });
1543        assert_eq!(
1544            policy.check_operation("nonexistent", "anything"),
1545            RbacDecision::Allow
1546        );
1547    }
1548
1549    // -- current_role / current_identity tests --
1550
1551    #[test]
1552    fn current_role_returns_none_outside_scope() {
1553        assert!(current_role().is_none());
1554    }
1555
1556    #[test]
1557    fn current_identity_returns_none_outside_scope() {
1558        assert!(current_identity().is_none());
1559    }
1560
1561    // -- rbac_middleware integration tests --
1562
1563    use axum::{
1564        body::Body,
1565        http::{Method, Request, StatusCode},
1566    };
1567    use tower::ServiceExt as _;
1568
1569    fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1570        serde_json::json!({
1571            "jsonrpc": "2.0",
1572            "id": 1,
1573            "method": "tools/call",
1574            "params": {
1575                "name": tool,
1576                "arguments": args
1577            }
1578        })
1579        .to_string()
1580    }
1581
1582    fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
1583        axum::Router::new()
1584            .route("/mcp", axum::routing::post(|| async { "ok" }))
1585            .layer(axum::middleware::from_fn(move |req, next| {
1586                let p = Arc::clone(&policy);
1587                rbac_middleware(p, None, req, next)
1588            }))
1589    }
1590
1591    fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
1592        axum::Router::new()
1593            .route("/mcp", axum::routing::post(|| async { "ok" }))
1594            .layer(axum::middleware::from_fn(
1595                move |mut req: Request<Body>, next: Next| {
1596                    let p = Arc::clone(&policy);
1597                    let id = identity.clone();
1598                    async move {
1599                        req.extensions_mut().insert(id);
1600                        rbac_middleware(p, None, req, next).await
1601                    }
1602                },
1603            ))
1604    }
1605
1606    #[tokio::test]
1607    async fn middleware_passes_non_post() {
1608        let policy = Arc::new(test_policy());
1609        let app = rbac_router(policy);
1610        // GET passes through even without identity.
1611        let req = Request::builder()
1612            .method(Method::GET)
1613            .uri("/mcp")
1614            .body(Body::empty())
1615            .unwrap();
1616        // GET on a POST-only route returns 405, but the middleware itself
1617        // doesn't block it -- it returns next.run(req).
1618        let resp = app.oneshot(req).await.unwrap();
1619        assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
1620    }
1621
1622    #[tokio::test]
1623    async fn middleware_denies_without_identity() {
1624        let policy = Arc::new(test_policy());
1625        let app = rbac_router(policy);
1626        let body = tool_call_body("resource_list", &serde_json::json!({}));
1627        let req = Request::builder()
1628            .method(Method::POST)
1629            .uri("/mcp")
1630            .header("content-type", "application/json")
1631            .body(Body::from(body))
1632            .unwrap();
1633        let resp = app.oneshot(req).await.unwrap();
1634        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1635    }
1636
1637    #[tokio::test]
1638    async fn middleware_allows_permitted_tool() {
1639        let policy = Arc::new(test_policy());
1640        let id = AuthIdentity {
1641            method: crate::auth::AuthMethod::BearerToken,
1642            name: "alice".into(),
1643            role: "viewer".into(),
1644            raw_token: None,
1645            sub: None,
1646        };
1647        let app = rbac_router_with_identity(policy, id);
1648        let body = tool_call_body("resource_list", &serde_json::json!({}));
1649        let req = Request::builder()
1650            .method(Method::POST)
1651            .uri("/mcp")
1652            .header("content-type", "application/json")
1653            .body(Body::from(body))
1654            .unwrap();
1655        let resp = app.oneshot(req).await.unwrap();
1656        assert_eq!(resp.status(), StatusCode::OK);
1657    }
1658
1659    #[tokio::test]
1660    async fn middleware_denies_unpermitted_tool() {
1661        let policy = Arc::new(test_policy());
1662        let id = AuthIdentity {
1663            method: crate::auth::AuthMethod::BearerToken,
1664            name: "alice".into(),
1665            role: "viewer".into(),
1666            raw_token: None,
1667            sub: None,
1668        };
1669        let app = rbac_router_with_identity(policy, id);
1670        let body = tool_call_body("resource_delete", &serde_json::json!({}));
1671        let req = Request::builder()
1672            .method(Method::POST)
1673            .uri("/mcp")
1674            .header("content-type", "application/json")
1675            .body(Body::from(body))
1676            .unwrap();
1677        let resp = app.oneshot(req).await.unwrap();
1678        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1679    }
1680
1681    #[tokio::test]
1682    async fn middleware_passes_non_tool_call_post() {
1683        let policy = Arc::new(test_policy());
1684        let id = AuthIdentity {
1685            method: crate::auth::AuthMethod::BearerToken,
1686            name: "alice".into(),
1687            role: "viewer".into(),
1688            raw_token: None,
1689            sub: None,
1690        };
1691        let app = rbac_router_with_identity(policy, id);
1692        // A non-tools/call JSON-RPC (e.g. resources/list) passes through.
1693        let body = serde_json::json!({
1694            "jsonrpc": "2.0",
1695            "id": 1,
1696            "method": "resources/list"
1697        })
1698        .to_string();
1699        let req = Request::builder()
1700            .method(Method::POST)
1701            .uri("/mcp")
1702            .header("content-type", "application/json")
1703            .body(Body::from(body))
1704            .unwrap();
1705        let resp = app.oneshot(req).await.unwrap();
1706        assert_eq!(resp.status(), StatusCode::OK);
1707    }
1708
1709    #[tokio::test]
1710    async fn middleware_enforces_argument_allowlist() {
1711        let policy = Arc::new(test_policy());
1712        let id = AuthIdentity {
1713            method: crate::auth::AuthMethod::BearerToken,
1714            name: "dev".into(),
1715            role: "restricted-exec".into(),
1716            raw_token: None,
1717            sub: None,
1718        };
1719        // Allowed command
1720        let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
1721        let body = tool_call_body(
1722            "resource_exec",
1723            &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
1724        );
1725        let req = Request::builder()
1726            .method(Method::POST)
1727            .uri("/mcp")
1728            .body(Body::from(body))
1729            .unwrap();
1730        let resp = app.oneshot(req).await.unwrap();
1731        assert_eq!(resp.status(), StatusCode::OK);
1732
1733        // Denied command
1734        let app = rbac_router_with_identity(policy, id);
1735        let body = tool_call_body(
1736            "resource_exec",
1737            &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
1738        );
1739        let req = Request::builder()
1740            .method(Method::POST)
1741            .uri("/mcp")
1742            .body(Body::from(body))
1743            .unwrap();
1744        let resp = app.oneshot(req).await.unwrap();
1745        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1746    }
1747
1748    #[tokio::test]
1749    async fn middleware_disabled_policy_passes_everything() {
1750        let policy = Arc::new(RbacPolicy::disabled());
1751        let app = rbac_router(policy);
1752        // No identity, disabled policy -- should pass.
1753        let body = tool_call_body("anything", &serde_json::json!({}));
1754        let req = Request::builder()
1755            .method(Method::POST)
1756            .uri("/mcp")
1757            .body(Body::from(body))
1758            .unwrap();
1759        let resp = app.oneshot(req).await.unwrap();
1760        assert_eq!(resp.status(), StatusCode::OK);
1761    }
1762
1763    #[tokio::test]
1764    async fn middleware_batch_all_allowed_passes() {
1765        let policy = Arc::new(test_policy());
1766        let id = AuthIdentity {
1767            method: crate::auth::AuthMethod::BearerToken,
1768            name: "alice".into(),
1769            role: "viewer".into(),
1770            raw_token: None,
1771            sub: None,
1772        };
1773        let app = rbac_router_with_identity(policy, id);
1774        let body = serde_json::json!([
1775            {
1776                "jsonrpc": "2.0",
1777                "id": 1,
1778                "method": "tools/call",
1779                "params": { "name": "resource_list", "arguments": {} }
1780            },
1781            {
1782                "jsonrpc": "2.0",
1783                "id": 2,
1784                "method": "tools/call",
1785                "params": { "name": "system_info", "arguments": {} }
1786            }
1787        ])
1788        .to_string();
1789        let req = Request::builder()
1790            .method(Method::POST)
1791            .uri("/mcp")
1792            .header("content-type", "application/json")
1793            .body(Body::from(body))
1794            .unwrap();
1795        let resp = app.oneshot(req).await.unwrap();
1796        assert_eq!(resp.status(), StatusCode::OK);
1797    }
1798
1799    #[tokio::test]
1800    async fn middleware_batch_with_denied_call_rejects_entire_batch() {
1801        let policy = Arc::new(test_policy());
1802        let id = AuthIdentity {
1803            method: crate::auth::AuthMethod::BearerToken,
1804            name: "alice".into(),
1805            role: "viewer".into(),
1806            raw_token: None,
1807            sub: None,
1808        };
1809        let app = rbac_router_with_identity(policy, id);
1810        let body = serde_json::json!([
1811            {
1812                "jsonrpc": "2.0",
1813                "id": 1,
1814                "method": "tools/call",
1815                "params": { "name": "resource_list", "arguments": {} }
1816            },
1817            {
1818                "jsonrpc": "2.0",
1819                "id": 2,
1820                "method": "tools/call",
1821                "params": { "name": "resource_delete", "arguments": {} }
1822            }
1823        ])
1824        .to_string();
1825        let req = Request::builder()
1826            .method(Method::POST)
1827            .uri("/mcp")
1828            .header("content-type", "application/json")
1829            .body(Body::from(body))
1830            .unwrap();
1831        let resp = app.oneshot(req).await.unwrap();
1832        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1833    }
1834
1835    #[tokio::test]
1836    async fn middleware_batch_mixed_allowed_and_denied_rejects() {
1837        let policy = Arc::new(test_policy());
1838        let id = AuthIdentity {
1839            method: crate::auth::AuthMethod::BearerToken,
1840            name: "dev".into(),
1841            role: "restricted-exec".into(),
1842            raw_token: None,
1843            sub: None,
1844        };
1845        let app = rbac_router_with_identity(policy, id);
1846        let body = serde_json::json!([
1847            {
1848                "jsonrpc": "2.0",
1849                "id": 1,
1850                "method": "tools/call",
1851                "params": {
1852                    "name": "resource_exec",
1853                    "arguments": { "cmd": "ls -la", "host": "dev-1" }
1854                }
1855            },
1856            {
1857                "jsonrpc": "2.0",
1858                "id": 2,
1859                "method": "tools/call",
1860                "params": {
1861                    "name": "resource_exec",
1862                    "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
1863                }
1864            }
1865        ])
1866        .to_string();
1867        let req = Request::builder()
1868            .method(Method::POST)
1869            .uri("/mcp")
1870            .header("content-type", "application/json")
1871            .body(Body::from(body))
1872            .unwrap();
1873        let resp = app.oneshot(req).await.unwrap();
1874        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1875    }
1876
1877    // -- redact_arg / redaction_salt tests --
1878
1879    #[test]
1880    fn redact_with_salt_is_deterministic_per_salt() {
1881        let salt = b"unit-test-salt";
1882        let a = redact_with_salt(salt, "rm -rf /");
1883        let b = redact_with_salt(salt, "rm -rf /");
1884        assert_eq!(a, b, "same input + salt must yield identical hash");
1885        assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
1886        assert!(
1887            a.chars().all(|c| c.is_ascii_hexdigit()),
1888            "redacted hash must be lowercase hex: {a}"
1889        );
1890    }
1891
1892    #[test]
1893    fn redact_with_salt_differs_across_salts() {
1894        let v = "the-same-value";
1895        let h1 = redact_with_salt(b"salt-one", v);
1896        let h2 = redact_with_salt(b"salt-two", v);
1897        assert_ne!(
1898            h1, h2,
1899            "different salts must produce different hashes for the same value"
1900        );
1901    }
1902
1903    #[test]
1904    fn redact_with_salt_distinguishes_values() {
1905        let salt = b"k";
1906        let h1 = redact_with_salt(salt, "alpha");
1907        let h2 = redact_with_salt(salt, "beta");
1908        // Hash collisions on 32 bits are 1-in-4-billion; safe to assert.
1909        assert_ne!(h1, h2, "different values must produce different hashes");
1910    }
1911
1912    #[test]
1913    fn policy_with_configured_salt_redacts_consistently() {
1914        let cfg = RbacConfig {
1915            enabled: true,
1916            roles: vec![],
1917            redaction_salt: Some(SecretString::from("my-stable-salt")),
1918        };
1919        let p1 = RbacPolicy::new(&cfg);
1920        let p2 = RbacPolicy::new(&cfg);
1921        assert_eq!(
1922            p1.redact_arg("payload"),
1923            p2.redact_arg("payload"),
1924            "policies built from the same configured salt must agree"
1925        );
1926    }
1927
1928    #[test]
1929    fn policy_without_configured_salt_uses_process_salt() {
1930        let cfg = RbacConfig {
1931            enabled: true,
1932            roles: vec![],
1933            redaction_salt: None,
1934        };
1935        let p1 = RbacPolicy::new(&cfg);
1936        let p2 = RbacPolicy::new(&cfg);
1937        // Within one process, the lazy OnceLock salt is shared.
1938        assert_eq!(
1939            p1.redact_arg("payload"),
1940            p2.redact_arg("payload"),
1941            "process-wide salt must be consistent within one process"
1942        );
1943    }
1944
1945    #[test]
1946    fn redact_arg_is_fast_enough() {
1947        // Sanity floor: a single redaction should take well under 100 µs
1948        // even in unoptimized debug builds. Production criterion bench
1949        // (see H-T4 plan) will assert a stricter <10 µs threshold.
1950        let salt = b"perf-sanity-salt-32-bytes-padded";
1951        let value = "x".repeat(256);
1952        let start = std::time::Instant::now();
1953        let _ = redact_with_salt(salt, &value);
1954        let elapsed = start.elapsed();
1955        assert!(
1956            elapsed < Duration::from_millis(5),
1957            "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
1958        );
1959    }
1960
1961    // -- enforce_tool_policy identity propagation regression test (BUG H-S3) --
1962
1963    /// Regression: when `enforce_tool_policy` denied a request, the deny
1964    /// log used to read `current_identity()`, which was always `None` at
1965    /// that point because the task-local context is installed *after*
1966    /// policy enforcement. The fix passes `identity_name` explicitly.
1967    ///
1968    /// We assert the deny path returns 403 (the visible behaviour).
1969    /// The log-content assertion lives behind tracing-test which we have
1970    /// not yet added as a dev-dep; the explicit-parameter signature alone
1971    /// makes the previous bug structurally impossible.
1972    #[tokio::test]
1973    async fn deny_path_uses_explicit_identity_not_task_local() {
1974        let policy = Arc::new(test_policy());
1975        let id = AuthIdentity {
1976            method: crate::auth::AuthMethod::BearerToken,
1977            name: "alice-the-auditor".into(),
1978            role: "viewer".into(),
1979            raw_token: None,
1980            sub: None,
1981        };
1982        let app = rbac_router_with_identity(policy, id);
1983        // viewer is not allowed to call resource_delete -> 403.
1984        let body = tool_call_body("resource_delete", &serde_json::json!({}));
1985        let req = Request::builder()
1986            .method(Method::POST)
1987            .uri("/mcp")
1988            .header("content-type", "application/json")
1989            .body(Body::from(body))
1990            .unwrap();
1991        let resp = app.oneshot(req).await.unwrap();
1992        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1993    }
1994
1995    // -- M2 regression: non-string argument values bypass allowlist --
1996
1997    fn restricted_exec_identity() -> AuthIdentity {
1998        AuthIdentity {
1999            method: crate::auth::AuthMethod::BearerToken,
2000            name: "carol".into(),
2001            role: "restricted-exec".into(),
2002            raw_token: None,
2003            sub: None,
2004        }
2005    }
2006
2007    #[test]
2008    fn has_argument_allowlist_matches_configured_tool_argument() {
2009        let policy = test_policy();
2010        assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2011        assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2012        assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2013        assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2014    }
2015
2016    #[tokio::test]
2017    async fn array_arg_with_matching_allowlist_is_denied() {
2018        let policy = Arc::new(test_policy());
2019        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2020        let body = tool_call_body(
2021            "resource_exec",
2022            &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2023        );
2024        let req = Request::builder()
2025            .method(Method::POST)
2026            .uri("/mcp")
2027            .header("content-type", "application/json")
2028            .body(Body::from(body))
2029            .unwrap();
2030        let resp = app.oneshot(req).await.unwrap();
2031        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2032    }
2033
2034    #[tokio::test]
2035    async fn object_arg_with_matching_allowlist_is_denied() {
2036        let policy = Arc::new(test_policy());
2037        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2038        let body = tool_call_body(
2039            "resource_exec",
2040            &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2041        );
2042        let req = Request::builder()
2043            .method(Method::POST)
2044            .uri("/mcp")
2045            .header("content-type", "application/json")
2046            .body(Body::from(body))
2047            .unwrap();
2048        let resp = app.oneshot(req).await.unwrap();
2049        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2050    }
2051
2052    #[tokio::test]
2053    async fn number_arg_with_matching_allowlist_is_denied() {
2054        let policy = Arc::new(test_policy());
2055        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2056        let body = tool_call_body(
2057            "resource_exec",
2058            &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2059        );
2060        let req = Request::builder()
2061            .method(Method::POST)
2062            .uri("/mcp")
2063            .header("content-type", "application/json")
2064            .body(Body::from(body))
2065            .unwrap();
2066        let resp = app.oneshot(req).await.unwrap();
2067        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2068    }
2069
2070    #[tokio::test]
2071    async fn bool_arg_with_matching_allowlist_is_denied() {
2072        let policy = Arc::new(test_policy());
2073        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2074        let body = tool_call_body(
2075            "resource_exec",
2076            &serde_json::json!({ "host": "dev-1", "cmd": true }),
2077        );
2078        let req = Request::builder()
2079            .method(Method::POST)
2080            .uri("/mcp")
2081            .header("content-type", "application/json")
2082            .body(Body::from(body))
2083            .unwrap();
2084        let resp = app.oneshot(req).await.unwrap();
2085        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2086    }
2087
2088    #[tokio::test]
2089    async fn null_arg_with_matching_allowlist_is_denied() {
2090        let policy = Arc::new(test_policy());
2091        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2092        let body = tool_call_body(
2093            "resource_exec",
2094            &serde_json::json!({ "host": "dev-1", "cmd": null }),
2095        );
2096        let req = Request::builder()
2097            .method(Method::POST)
2098            .uri("/mcp")
2099            .header("content-type", "application/json")
2100            .body(Body::from(body))
2101            .unwrap();
2102        let resp = app.oneshot(req).await.unwrap();
2103        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2104    }
2105
2106    #[tokio::test]
2107    async fn non_string_arg_without_allowlist_is_passthrough() {
2108        // ops has no argument_allowlist for any (tool, arg) tuple, so
2109        // non-string values must reach the handler. resource_exec is in
2110        // ops's allow list so the call should not be rejected by RBAC.
2111        let policy = Arc::new(test_policy());
2112        let id = AuthIdentity {
2113            method: crate::auth::AuthMethod::BearerToken,
2114            name: "olivia".into(),
2115            role: "ops".into(),
2116            raw_token: None,
2117            sub: None,
2118        };
2119        let app = rbac_router_with_identity(policy, id);
2120        let body = tool_call_body(
2121            "resource_exec",
2122            &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2123        );
2124        let req = Request::builder()
2125            .method(Method::POST)
2126            .uri("/mcp")
2127            .header("content-type", "application/json")
2128            .body(Body::from(body))
2129            .unwrap();
2130        let resp = app.oneshot(req).await.unwrap();
2131        assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2132    }
2133
2134    #[tokio::test]
2135    async fn string_arg_in_allowlist_still_passes() {
2136        let policy = Arc::new(test_policy());
2137        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2138        let body = tool_call_body(
2139            "resource_exec",
2140            &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2141        );
2142        let req = Request::builder()
2143            .method(Method::POST)
2144            .uri("/mcp")
2145            .header("content-type", "application/json")
2146            .body(Body::from(body))
2147            .unwrap();
2148        let resp = app.oneshot(req).await.unwrap();
2149        assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2150    }
2151}