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