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