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, path::PathBuf, 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::RmcpServerKitError};
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///
113/// Returns `None` outside a request context or when auth used mTLS/API-key.
114/// Tool handlers use this for downstream token passthrough.
115///
116/// The returned value is wrapped in [`SecretString`] so it does not leak
117/// via `Debug`/`Display`/serde. Call `.expose_secret()` only when the
118/// raw value is actually needed (e.g. as the `Authorization` header on
119/// an outbound HTTP request).
120///
121/// An empty token is treated as absent (returns `None`); this preserves
122/// backward compatibility with the prior `Option<String>` API where the
123/// empty default sentinel meant "no token".
124#[must_use]
125pub fn current_token() -> Option<SecretString> {
126    CURRENT_TOKEN
127        .try_with(|t| {
128            if t.expose_secret().is_empty() {
129                None
130            } else {
131                Some(t.clone())
132            }
133        })
134        .ok()
135        .flatten()
136}
137
138/// Get the JWT `sub` claim (stable user ID, e.g. Keycloak UUID).
139/// Returns `None` outside a request context or for non-JWT auth.
140/// Use for stable per-user keying (token store, etc.).
141#[must_use]
142pub fn current_sub() -> Option<String> {
143    CURRENT_SUB
144        .try_with(Clone::clone)
145        .ok()
146        .filter(|s| !s.is_empty())
147}
148
149/// Run a future with `CURRENT_TOKEN` set so that [`current_token()`] returns
150/// the given value inside the future.
151///
152/// Useful when MCP tool handlers need the raw bearer token but run in a
153/// spawned task where the RBAC middleware's task-local scope is no longer
154/// active.
155pub async fn with_token_scope<F: Future>(token: SecretString, f: F) -> F::Output {
156    CURRENT_TOKEN.scope(token, f).await
157}
158
159/// Run a future with all task-locals (`CURRENT_ROLE`, `CURRENT_IDENTITY`,
160/// `CURRENT_TOKEN`, `CURRENT_SUB`) set.
161///
162/// Use this when re-establishing the full RBAC context in spawned tasks
163/// (e.g. rmcp session tasks) where the middleware's scope is no longer
164/// 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/// By default this constrains the value only **when the argument is
238/// present** -- omitting it entirely skips the check. Set
239/// [`required`](Self::required) to also demand the argument be supplied.
240//
241// NOTE(future-pr): typed pre-tokenized argument matcher (CHANGELOG.md
242// "future release" promise).
243// Scope (Oracle-approved, internal-only, patch-safe):
244//   - Keep `ArgumentAllowlist` public shape UNCHANGED (wire/config stability).
245//     (The later addition of `required` is additive and serde-defaulted, so
246//     it preserves that property; the compiled IR must carry it through.)
247//   - In `RbacPolicy::new`, compile each allowlist once into a private
248//     `CompiledArgumentAllowlist` IR:
249//       * pre-resolve the `tool` selector: exact vs glob.
250//       * pre-tokenize first-token allowlists.
251//       * pre-tokenize basename allowlists.
252//       * carry the `required` flag so presence enforcement survives.
253//   - At request time (`has_argument_allowlist` / `argument_allowed`),
254//     `shlex::split` each constrained argument once, then lookup in the
255//     compiled IR.
256//   - Required equivalence test matrix: exact tool names, globbed tool
257//     names, basename matches, quoted paths, fail-closed parse errors,
258//     required-present / required-absent.
259//   - Profile before merge; justify by maintainability if perf delta <5%.
260#[derive(Debug, Clone, Deserialize)]
261#[non_exhaustive]
262pub struct ArgumentAllowlist {
263    /// Tool name to match (exact or glob, e.g. `"run_query"`).
264    pub tool: String,
265    /// Argument key whose value is checked (e.g. `"cmd"`, `"query"`).
266    pub argument: String,
267    /// Permitted first-token values. Empty means unrestricted.
268    #[serde(default)]
269    pub allowed: Vec<String>,
270    /// Require the argument to be present and string-valued.
271    ///
272    /// Defaults to `false`, preserving the historical semantics: an
273    /// allowlist constrains the value when the argument is supplied, and a
274    /// caller omitting it passes unchecked. That is safe when the tool's
275    /// input schema already marks the argument required, but fails open
276    /// when the handler substitutes a default for a missing value.
277    ///
278    /// When `true`, a call that omits the argument -- or supplies a
279    /// non-string -- is denied with 403, independently of `allowed`. Setting
280    /// `required` with an empty `allowed` therefore means "must be supplied
281    /// as a string, any value accepted".
282    #[serde(default)]
283    pub required: bool,
284}
285
286impl ArgumentAllowlist {
287    /// Create an argument allowlist for a tool.
288    ///
289    /// The argument is optional by default; use
290    /// [`with_required`](Self::with_required) to demand its presence.
291    #[must_use]
292    pub fn new(tool: impl Into<String>, argument: impl Into<String>, allowed: Vec<String>) -> Self {
293        Self {
294            tool: tool.into(),
295            argument: argument.into(),
296            allowed,
297            required: false,
298        }
299    }
300
301    /// Require the argument to be present and string-valued.
302    #[must_use]
303    pub const fn with_required(mut self, required: bool) -> Self {
304        self.required = required;
305        self
306    }
307}
308
309fn default_hosts() -> Vec<String> {
310    vec!["*".into()]
311}
312
313/// Top-level RBAC configuration (deserializable from TOML).
314#[derive(Debug, Clone, Default, Deserialize)]
315#[non_exhaustive]
316pub struct RbacConfig {
317    /// Master switch -- when false, the RBAC middleware is not installed.
318    #[serde(default)]
319    pub enabled: bool,
320    /// Role definitions available to identities.
321    #[serde(default)]
322    pub roles: Vec<RoleConfig>,
323    /// Optional stable HMAC key (any length) used to redact argument
324    /// values in deny logs. When set, redacted hashes are stable across
325    /// process restarts (useful for log correlation across deploys).
326    /// When `None`, a random 32-byte key is generated per process at
327    /// first use; redacted hashes change every restart.
328    ///
329    /// The key is wrapped in [`SecretString`] so it never leaks via
330    /// `Debug`/`Display`/serde and is zeroized on drop.
331    #[serde(default)]
332    pub redaction_salt: Option<SecretString>,
333}
334
335impl RbacConfig {
336    /// Create an enabled RBAC config with the given roles.
337    #[must_use]
338    pub fn with_roles(roles: Vec<RoleConfig>) -> Self {
339        Self {
340            enabled: true,
341            roles,
342            redaction_salt: None,
343        }
344    }
345}
346
347/// Result of an RBAC policy check.
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349#[non_exhaustive]
350pub enum RbacDecision {
351    /// Caller is permitted to perform the requested operation.
352    Allow,
353    /// Caller is denied access.
354    Deny,
355}
356
357/// Summary of a single role, produced by [`RbacPolicy::summary`].
358#[derive(Debug, Clone, serde::Serialize)]
359#[non_exhaustive]
360pub struct RbacRoleSummary {
361    /// Role name.
362    pub name: String,
363    /// Number of allow entries.
364    pub allow: usize,
365    /// Number of deny entries.
366    pub deny: usize,
367    /// Number of host patterns.
368    pub hosts: usize,
369    /// Number of argument allowlist entries.
370    pub argument_allowlists: usize,
371}
372
373/// Summary of the whole RBAC policy, produced by [`RbacPolicy::summary`].
374#[derive(Debug, Clone, serde::Serialize)]
375#[non_exhaustive]
376pub struct RbacPolicySummary {
377    /// Whether RBAC enforcement is active.
378    pub enabled: bool,
379    /// Per-role summaries.
380    pub roles: Vec<RbacRoleSummary>,
381}
382
383/// Compiled RBAC policy for fast lookup.
384///
385/// Built from [`RbacConfig`] at startup.  All lookups are O(n) over the
386/// role's allow/deny/host lists, which is fine for the expected cardinality
387/// (a handful of roles with tens of entries each).
388#[derive(Debug, Clone)]
389#[non_exhaustive]
390pub struct RbacPolicy {
391    roles: Vec<RoleConfig>,
392    enabled: bool,
393    /// HMAC key used to redact argument values in deny logs.
394    /// Either a configured stable salt or a per-process random salt.
395    redaction_salt: Arc<SecretString>,
396}
397
398impl RbacPolicy {
399    /// Build a policy from config.  When `config.enabled` is false, all
400    /// checks return [`RbacDecision::Allow`].
401    #[must_use]
402    pub fn new(config: &RbacConfig) -> Self {
403        let salt = config
404            .redaction_salt
405            .clone()
406            .unwrap_or_else(|| process_redaction_salt().clone());
407        Self {
408            roles: config.roles.clone(),
409            enabled: config.enabled,
410            redaction_salt: Arc::new(salt),
411        }
412    }
413
414    /// Create a policy that always allows (RBAC disabled).
415    #[must_use]
416    pub fn disabled() -> Self {
417        Self {
418            roles: Vec::new(),
419            enabled: false,
420            redaction_salt: Arc::new(process_redaction_salt().clone()),
421        }
422    }
423
424    /// Whether RBAC enforcement is active.
425    #[must_use]
426    pub fn is_enabled(&self) -> bool {
427        self.enabled
428    }
429
430    /// Summarize the policy for diagnostics (admin endpoint).
431    ///
432    /// Returns `(enabled, role_count, per_role_stats)` where each stat is
433    /// `(name, allow_count, deny_count, host_count, argument_allowlist_count)`.
434    #[must_use]
435    pub fn summary(&self) -> RbacPolicySummary {
436        let roles = self
437            .roles
438            .iter()
439            .map(|r| RbacRoleSummary {
440                name: r.name.clone(),
441                allow: r.allow.len(),
442                deny: r.deny.len(),
443                hosts: r.hosts.len(),
444                argument_allowlists: r.argument_allowlists.len(),
445            })
446            .collect();
447        RbacPolicySummary {
448            enabled: self.enabled,
449            roles,
450        }
451    }
452
453    /// Check whether `role` may perform `operation` (ignoring host).
454    ///
455    /// Use this for tools that don't target a specific host (e.g. `ping`,
456    /// `list_hosts`).
457    #[must_use]
458    pub fn check_operation(&self, role: &str, operation: &str) -> RbacDecision {
459        if !self.enabled {
460            return RbacDecision::Allow;
461        }
462        let Some(role_cfg) = self.find_role(role) else {
463            return RbacDecision::Deny;
464        };
465        if role_cfg.deny.iter().any(|d| d == operation) {
466            return RbacDecision::Deny;
467        }
468        if role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
469            return RbacDecision::Allow;
470        }
471        RbacDecision::Deny
472    }
473
474    /// Check whether `role` may perform `operation` on `host`.
475    ///
476    /// Evaluation order:
477    /// 1. If RBAC is disabled, allow.
478    /// 2. Check operation permission (deny overrides allow).
479    /// 3. Check host visibility via glob matching.
480    #[must_use]
481    pub fn check(&self, role: &str, operation: &str, host: &str) -> RbacDecision {
482        if !self.enabled {
483            return RbacDecision::Allow;
484        }
485        let Some(role_cfg) = self.find_role(role) else {
486            return RbacDecision::Deny;
487        };
488        if role_cfg.deny.iter().any(|d| d == operation) {
489            return RbacDecision::Deny;
490        }
491        if !role_cfg.allow.iter().any(|a| a == "*" || a == operation) {
492            return RbacDecision::Deny;
493        }
494        if !Self::host_matches(&role_cfg.hosts, host) {
495            return RbacDecision::Deny;
496        }
497        RbacDecision::Allow
498    }
499
500    /// Check whether `role` can see `host` at all (for `list_hosts` filtering).
501    #[must_use]
502    pub fn host_visible(&self, role: &str, host: &str) -> bool {
503        if !self.enabled {
504            return true;
505        }
506        let Some(role_cfg) = self.find_role(role) else {
507            return false;
508        };
509        Self::host_matches(&role_cfg.hosts, host)
510    }
511
512    /// Get the list of hosts patterns for a role.
513    #[must_use]
514    pub fn host_patterns(&self, role: &str) -> Option<&[String]> {
515        self.find_role(role).map(|r| r.hosts.as_slice())
516    }
517
518    /// Check whether `value` passes the argument allowlists for `tool` under `role`.
519    ///
520    /// If the role has no matching `argument_allowlists` entry for the tool,
521    /// all values are allowed. When a matching entry exists, `value` is
522    /// tokenized using POSIX-shell-like lexical rules ([`shlex::split`])
523    /// and its first argv element (or the `/`-basename of that element)
524    /// must appear in the `allowed` list.
525    ///
526    /// **Scope of the contract.** This matcher targets consumers that
527    /// interpret string arguments as POSIX-shell-like command lines on
528    /// Unix-like systems (e.g. anything that subsequently feeds the value
529    /// through `shlex` or an equivalent splitter before `execve`). It
530    /// does **not** model real shell *execution* grammar (`FOO=1 cmd`,
531    /// expansion, command substitution, redirection, operators) or
532    /// Windows command-line tokenization (`CommandLineToArgvW`,
533    /// `cmd.exe`, PowerShell). Consumers in those regimes remain subject
534    /// to a parser differential and must validate at their own boundary.
535    ///
536    /// **Fail-closed cases (all return `false` when a matching allowlist
537    /// entry exists):**
538    ///
539    /// - `value` fails to parse as a POSIX-shell-like command line
540    ///   (e.g. unbalanced quotes, dangling escape).
541    /// - `value` parses to zero tokens (empty input).
542    /// - The first parsed token is the empty string (e.g.
543    ///   `value = r#""""#` parses to `Some(vec![""])`). An empty argv
544    ///   element is never a runnable executable, so we reject even when
545    ///   `""` is in the allowlist.
546    #[must_use]
547    pub fn argument_allowed(&self, role: &str, tool: &str, argument: &str, value: &str) -> bool {
548        if !self.enabled {
549            return true;
550        }
551        let Some(role_cfg) = self.find_role(role) else {
552            return false;
553        };
554        for al in &role_cfg.argument_allowlists {
555            if al.tool != tool && !glob_match(&al.tool, tool) {
556                continue;
557            }
558            if al.argument != argument {
559                continue;
560            }
561            if al.allowed.is_empty() {
562                continue;
563            }
564            // Tokenize per POSIX-shell-like rules so quoted paths with
565            // spaces match what an equivalently-tokenizing consumer
566            // would actually run, and malformed shell syntax (unbalanced
567            // quotes, dangling escapes) fails closed.
568            let Some(tokens) = shlex::split(value) else {
569                return false;
570            };
571            let Some(first_token) = tokens.first() else {
572                return false;
573            };
574            // A well-formed but empty first argv element (e.g.
575            // value = r#""""#) is never a runnable executable. Fail
576            // closed even if "" appears in the allowlist.
577            if first_token.is_empty() {
578                return false;
579            }
580            // Also match against the basename if it's a path. POSIX
581            // separator only; Windows-style backslash paths are out of
582            // scope and will not basename-match (see crate-level docs).
583            let basename = first_token
584                .rsplit('/')
585                .next()
586                .unwrap_or(first_token.as_str());
587            if !al.allowed.iter().any(|a| a == first_token || a == basename) {
588                return false;
589            }
590        }
591        true
592    }
593
594    /// Return `true` if `(role, tool, argument)` has any non-empty
595    /// allowlist entry configured.
596    ///
597    /// Used by the tools/call middleware to decide whether non-string
598    /// JSON values must be rejected (M2 fix). When this returns `true`,
599    /// the value at `argument` must be a JSON string and pass
600    /// [`Self::argument_allowed`]; otherwise the call is denied with
601    /// 403. When this returns `false`, the value is unconstrained by
602    /// allowlist policy.
603    #[must_use]
604    pub fn has_argument_allowlist(&self, role: &str, tool: &str, argument: &str) -> bool {
605        if !self.enabled {
606            return false;
607        }
608        let Some(role_cfg) = self.find_role(role) else {
609            return false;
610        };
611        role_cfg.argument_allowlists.iter().any(|al| {
612            (al.tool == tool || glob_match(&al.tool, tool))
613                && al.argument == argument
614                && !al.allowed.is_empty()
615        })
616    }
617
618    /// Return the role config for a given role name.
619    fn find_role(&self, name: &str) -> Option<&RoleConfig> {
620        self.roles.iter().find(|r| r.name == name)
621    }
622
623    /// Name of the first `required` argument that `args` fails to supply as a
624    /// JSON string, or `None` when every requirement is met.
625    ///
626    /// `args` is `None` when the call carried no `arguments` object at all (or
627    /// carried a non-object); that must still be evaluated, otherwise omitting
628    /// the object would skip every requirement.
629    ///
630    /// Kept private: this is middleware-internal enforcement, unlike
631    /// [`Self::has_argument_allowlist`] / [`Self::argument_allowed`], which
632    /// expose value-policy evaluation to consumers.
633    fn missing_required_argument(
634        &self,
635        role: &str,
636        tool: &str,
637        args: Option<&serde_json::Map<String, serde_json::Value>>,
638    ) -> Option<&str> {
639        if !self.enabled {
640            return None;
641        }
642        let role_cfg = self.find_role(role)?;
643        role_cfg
644            .argument_allowlists
645            .iter()
646            .filter(|al| al.required)
647            // Same exact-or-glob selector as `argument_allowed` /
648            // `has_argument_allowlist`; diverging here would make a globbed
649            // tool pattern enforce values but not presence.
650            .filter(|al| al.tool == tool || glob_match(&al.tool, tool))
651            .find(|al| {
652                !args.is_some_and(|a| {
653                    a.get(&al.argument)
654                        .is_some_and(serde_json::Value::is_string)
655                })
656            })
657            .map(|al| al.argument.as_str())
658    }
659
660    /// Check if a host name matches any of the given glob patterns.
661    fn host_matches(patterns: &[String], host: &str) -> bool {
662        patterns.iter().any(|p| glob_match(p, host))
663    }
664
665    /// HMAC-SHA256 the given argument value with this policy's redaction
666    /// salt and return the first 8 hex characters (4 bytes / 32 bits).
667    ///
668    /// 32 bits is enough entropy for log correlation (1-in-4-billion
669    /// collision per pair) while being far short of any preimage attack
670    /// surface for an attacker reading logs. The HMAC construction
671    /// guarantees that even short or low-entropy values cannot be
672    /// recovered without the key.
673    #[must_use]
674    pub fn redact_arg(&self, value: &str) -> String {
675        redact_with_salt(self.redaction_salt.expose_secret().as_bytes(), value)
676    }
677}
678
679/// Process-wide random redaction salt, lazily generated on first use.
680/// Used when [`RbacConfig::redaction_salt`] is `None`.
681fn process_redaction_salt() -> &'static SecretString {
682    use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
683    static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
684    PROCESS_SALT.get_or_init(|| {
685        let mut bytes = [0u8; 32];
686        rand::fill(&mut bytes);
687        // base64-encode so the SecretString is valid UTF-8; the HMAC
688        // accepts arbitrary key bytes regardless.
689        SecretString::from(STANDARD_NO_PAD.encode(bytes))
690    })
691}
692
693/// HMAC-SHA256(`salt`, `value`) → first 8 hex chars.
694///
695/// Pulled out as a free function so it can be unit-tested and benchmarked
696/// without constructing a full [`RbacPolicy`].
697fn redact_with_salt(salt: &[u8], value: &str) -> String {
698    use std::fmt::Write as _;
699
700    use sha2::Digest as _;
701
702    type HmacSha256 = Hmac<Sha256>;
703    // HMAC-SHA256 accepts keys of any byte length: the spec pads short
704    // keys with zeros and hashes long keys, so `new_from_slice` is
705    // infallible here. We still defensively re-key with a SHA-256 of
706    // the salt if construction ever fails (e.g. future hmac upstream
707    // tightens the contract); both branches produce a valid keyed MAC.
708    let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
709        m
710    } else {
711        let digest = Sha256::digest(salt);
712        #[allow(
713            clippy::expect_used,
714            reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
715        )]
716        HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
717    };
718    mac.update(value.as_bytes());
719    let bytes = mac.finalize().into_bytes();
720    // 4 bytes → 8 hex chars.
721    let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
722    let mut out = String::with_capacity(8);
723    for b in prefix {
724        let _ = write!(out, "{b:02x}");
725    }
726    out
727}
728
729// -- RBAC middleware --
730
731/// Axum middleware that enforces RBAC and per-IP tool rate limiting on
732/// MCP tool calls.
733///
734/// Inspects POST request bodies for `tools/call` JSON-RPC messages,
735/// extracts the tool name and `host` argument, and checks the
736/// [`RbacPolicy`] against the [`AuthIdentity`] set by the auth middleware.
737///
738/// When a `tool_limiter` is provided, tool invocations are rate-limited
739/// per source IP regardless of whether RBAC is enabled (MCP spec: servers
740/// MUST rate limit tool invocations).
741///
742/// Non-POST requests and non-tool-call messages pass through unchanged.
743/// The caller's role is stored in task-local storage for use by tool
744/// handlers (e.g. `list_hosts` host filtering via [`current_role()`]).
745// NOTE: cognitive complexity reduced from 43/25 by extracting
746// `enforce_tool_policy` and `enforce_rate_limit`. Remaining flow is a
747// linear body-collect + JSON-RPC parse + dispatch, intentionally left
748// inline to keep the request lifecycle visible at a glance.
749#[allow(
750    clippy::too_many_lines,
751    reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
752)]
753pub(crate) async fn rbac_middleware(
754    policy: Arc<RbacPolicy>,
755    tool_limiter: Option<Arc<ToolRateLimiter>>,
756    req: Request<Body>,
757    next: Next,
758) -> Response {
759    // Only inspect POST requests - tool calls are POSTs.
760    if req.method() != Method::POST {
761        return next.run(req).await;
762    }
763
764    // Extract the rate-limit key (resolved client IP when trusted-forwarder
765    // mode is active, else the direct peer).
766    let peer_ip: Option<IpAddr> = crate::transport::limiter_client_ip(req.extensions());
767
768    // Extract caller identity and role (may be absent when auth is off).
769    let identity = req.extensions().get::<AuthIdentity>();
770    let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
771    let role = identity.map(|id| id.role.clone()).unwrap_or_default();
772    // Clone the SecretString end-to-end; an absent token becomes an empty
773    // SecretString sentinel (current_token() filters this out as None).
774    let raw_token: SecretString = identity
775        .and_then(|id| id.raw_token.clone())
776        .unwrap_or_else(|| SecretString::from(String::new()));
777    let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
778
779    // RBAC requires an authenticated identity.
780    if policy.is_enabled() && identity.is_none() {
781        return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
782    }
783
784    // Read the body for JSON-RPC inspection.
785    let (parts, body) = req.into_parts();
786    let bytes = match body.collect().await {
787        Ok(collected) => collected.to_bytes(),
788        Err(e) => {
789            tracing::error!(error = %e, "failed to read request body");
790            return (
791                StatusCode::INTERNAL_SERVER_ERROR,
792                "failed to read request body",
793            )
794                .into_response();
795        }
796    };
797
798    // Try to parse as JSON and inspect JSON-RPC tool calls, including batch arrays.
799    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
800        let tool_calls = extract_tool_calls(&json);
801        if !tool_calls.is_empty() {
802            for params in tool_calls {
803                if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_ip) {
804                    #[cfg(feature = "metrics")]
805                    crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
806                    return resp;
807                }
808                if policy.is_enabled()
809                    && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
810                {
811                    return resp;
812                }
813            }
814        }
815    }
816    // Non-parseable or non-tool-call requests pass through.
817
818    // Reconstruct the request with the consumed body.
819    let req = Request::from_parts(parts, Body::from(bytes));
820
821    // Set the caller's role and identity in task-local storage for the handler.
822    if role.is_empty() {
823        next.run(req).await
824    } else {
825        CURRENT_ROLE
826            .scope(
827                role,
828                CURRENT_IDENTITY.scope(
829                    identity_name,
830                    CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
831                ),
832            )
833            .await
834    }
835}
836
837/// Extract the `params` object for every top-level `tools/call` message.
838///
839/// Supports either a single JSON-RPC object or a JSON-RPC batch array. Any
840/// malformed elements are ignored so non-RPC payloads continue to pass through
841/// unchanged.
842fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
843    match value {
844        serde_json::Value::Object(map) => map
845            .get("method")
846            .and_then(serde_json::Value::as_str)
847            .filter(|method| *method == "tools/call")
848            .and_then(|_| map.get("params"))
849            .into_iter()
850            .collect(),
851        serde_json::Value::Array(items) => items
852            .iter()
853            .filter_map(|item| match item {
854                serde_json::Value::Object(map) => map
855                    .get("method")
856                    .and_then(serde_json::Value::as_str)
857                    .filter(|method| *method == "tools/call")
858                    .and_then(|_| map.get("params")),
859                serde_json::Value::Null
860                | serde_json::Value::Bool(_)
861                | serde_json::Value::Number(_)
862                | serde_json::Value::String(_)
863                | serde_json::Value::Array(_) => None,
864            })
865            .collect(),
866        serde_json::Value::Null
867        | serde_json::Value::Bool(_)
868        | serde_json::Value::Number(_)
869        | serde_json::Value::String(_) => Vec::new(),
870    }
871}
872
873/// Per-IP rate limit check for tool invocations. Returns `Some(response)`
874/// if the caller should be rejected.
875fn enforce_rate_limit(
876    tool_limiter: Option<&ToolRateLimiter>,
877    peer_ip: Option<IpAddr>,
878) -> Option<Response> {
879    let limiter = tool_limiter?;
880    let ip = peer_ip?;
881    if let Err(wait) = limiter.check_key_wait(&ip) {
882        tracing::warn!(%ip, "tool invocation rate limited");
883        return Some(
884            RmcpServerKitError::RateLimitedFor {
885                message: "too many tool invocations".into(),
886                retry_after: wait,
887            }
888            .into_response(),
889        );
890    }
891    None
892}
893
894/// Apply RBAC tool/host + argument-allowlist checks. Returns `Some(response)`
895/// when the caller must be rejected. Assumes `policy.is_enabled()`.
896///
897/// `identity_name` is passed explicitly (rather than read from
898/// [`current_identity()`]) because this function runs *before* the
899/// task-local context is installed by the middleware. Reading the
900/// task-local here would always yield `None`, producing deny logs with
901/// an empty `user` field.
902fn enforce_tool_policy(
903    policy: &RbacPolicy,
904    identity_name: &str,
905    role: &str,
906    params: &serde_json::Value,
907) -> Option<Response> {
908    let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
909    let host_value = params.get("arguments").and_then(|a| a.get("host"));
910
911    // M2 precedent (see `check_argument`): a caller-supplied `host` of the
912    // wrong JSON type must not silently downgrade the host-glob check to an
913    // operation-only check. `as_str()` on an array/object/number/bool/null
914    // yields `None`, which would route to `check_operation` and skip
915    // `RoleConfig.hosts` entirely -- letting a caller opt out of host
916    // restrictions by changing the argument's shape. Fail closed, and log
917    // the type rather than the value so no caller input is leaked.
918    if let Some(value) = host_value
919        && !value.is_string()
920    {
921        tracing::warn!(
922            user = %identity_name,
923            role = %role,
924            tool = tool_name,
925            value_type = json_value_type(value),
926            "non-string host argument rejected"
927        );
928        return Some(
929            RmcpServerKitError::Rbac(format!(
930                "argument 'host' must be a string for tool '{tool_name}'"
931            ))
932            .into_response(),
933        );
934    }
935    // Absent `host` still routes to `check_operation` by design: hostless
936    // tools (`ping`, `list_hosts`) legitimately carry no host argument.
937    let host = host_value.and_then(|h| h.as_str());
938
939    let decision = if let Some(host) = host {
940        policy.check(role, tool_name, host)
941    } else {
942        policy.check_operation(role, tool_name)
943    };
944    if decision == RbacDecision::Deny {
945        tracing::warn!(
946            user = %identity_name,
947            role = %role,
948            tool = tool_name,
949            host = host.unwrap_or("-"),
950            "RBAC denied"
951        );
952        return Some(
953            RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
954                .into_response(),
955        );
956    }
957
958    let args = params.get("arguments").and_then(|a| a.as_object());
959    if let Some(args) = args {
960        for (arg_key, arg_val) in args {
961            if let Some(resp) =
962                check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
963            {
964                return Some(resp);
965            }
966        }
967    }
968    check_required_arguments(policy, identity_name, role, tool_name, args)
969}
970
971/// Deny when a `required` argument is missing or not string-valued.
972///
973/// Absence can only be judged here: [`check_argument`] is keyed by a present
974/// argument and structurally cannot observe a missing one. This runs even when
975/// `args` is `None` -- i.e. the call carried no `arguments` object, or a
976/// non-object -- because returning early on that would let a caller skip every
977/// `required` constraint by omitting the object entirely.
978fn check_required_arguments(
979    policy: &RbacPolicy,
980    identity_name: &str,
981    role: &str,
982    tool_name: &str,
983    args: Option<&serde_json::Map<String, serde_json::Value>>,
984) -> Option<Response> {
985    let missing = policy.missing_required_argument(role, tool_name, args)?;
986    tracing::warn!(
987        user = %identity_name,
988        role = %role,
989        tool = tool_name,
990        argument = missing,
991        "required argument missing"
992    );
993    Some(
994        RmcpServerKitError::Rbac(format!(
995            "argument '{missing}' is required for tool '{tool_name}'"
996        ))
997        .into_response(),
998    )
999}
1000
1001fn check_argument(
1002    policy: &RbacPolicy,
1003    identity_name: &str,
1004    role: &str,
1005    tool_name: &str,
1006    arg_key: &str,
1007    arg_val: &serde_json::Value,
1008) -> Option<Response> {
1009    if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1010        return None;
1011    }
1012    let Some(val_str) = arg_val.as_str() else {
1013        // M2: an allowlist is configured for this argument but the
1014        // caller sent a non-string JSON value (array/object/number/
1015        // bool/null), which can never satisfy a `Vec<String>`
1016        // allowlist. Fail closed; log the type (not the value) so
1017        // operators see the rejected shape without leaking inputs.
1018        tracing::warn!(
1019            user = %identity_name,
1020            role = %role,
1021            tool = tool_name,
1022            argument = arg_key,
1023            value_type = json_value_type(arg_val),
1024            "non-string argument rejected by allowlist"
1025        );
1026        return Some(
1027            RmcpServerKitError::Rbac(format!(
1028                "argument '{arg_key}' must be a string for tool '{tool_name}'"
1029            ))
1030            .into_response(),
1031        );
1032    };
1033    if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1034        return None;
1035    }
1036    // Redact the raw value: log an HMAC-SHA256 prefix instead of
1037    // the literal string. Operators correlate hashes across log
1038    // lines without ever exposing potentially sensitive inputs
1039    // (paths, IDs, tokens accidentally passed as args, etc.).
1040    tracing::warn!(
1041        user = %identity_name,
1042        role = %role,
1043        tool = tool_name,
1044        argument = arg_key,
1045        arg_hmac = %policy.redact_arg(val_str),
1046        "argument not in allowlist"
1047    );
1048    Some(
1049        RmcpServerKitError::Rbac(format!(
1050            "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1051        ))
1052        .into_response(),
1053    )
1054}
1055
1056fn json_value_type(v: &serde_json::Value) -> &'static str {
1057    match v {
1058        serde_json::Value::Null => "null",
1059        serde_json::Value::Bool(_) => "bool",
1060        serde_json::Value::Number(_) => "number",
1061        serde_json::Value::String(_) => "string",
1062        serde_json::Value::Array(_) => "array",
1063        serde_json::Value::Object(_) => "object",
1064    }
1065}
1066
1067/// Simple glob matching: `*` matches any sequence of characters.
1068///
1069/// Supports multiple `*` wildcards anywhere in the pattern.
1070/// No `?`, `[...]`, or other advanced glob features.
1071///
1072/// All slice offsets are derived from `starts_with`/`ends_with`/`find`,
1073/// which guarantee char-boundary alignment; the `get(..)` accessors keep
1074/// that machine-checked (a violated invariant degrades to a non-match
1075/// instead of a panic).
1076fn glob_match(pattern: &str, text: &str) -> bool {
1077    let parts: Vec<&str> = pattern.split('*').collect();
1078    if parts.len() == 1 {
1079        // No wildcards - exact match.
1080        return pattern == text;
1081    }
1082
1083    // First part must match at the start (unless pattern starts with *).
1084    let pos = if let Some(&first) = parts.first()
1085        && !first.is_empty()
1086    {
1087        if !text.starts_with(first) {
1088            return false;
1089        }
1090        first.len()
1091    } else {
1092        0
1093    };
1094
1095    // Last part must match at the end (unless pattern ends with *).
1096    if let Some(&last) = parts.last()
1097        && !last.is_empty()
1098    {
1099        if !text.get(pos..).unwrap_or_default().ends_with(last) {
1100            return false;
1101        }
1102        // Shrink the search area so middle parts don't overlap with the suffix.
1103        let end = text.len() - last.len();
1104        if pos > end {
1105            return false;
1106        }
1107        // Check middle parts in the remaining region.
1108        let middle = text.get(pos..end).unwrap_or_default();
1109        let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1110        return match_middle(middle, middle_parts);
1111    }
1112
1113    // Pattern ends with * - just check middle parts.
1114    let middle = text.get(pos..).unwrap_or_default();
1115    let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1116    match_middle(middle, middle_parts)
1117}
1118
1119/// Match middle glob segments sequentially in `text`.
1120fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1121    for part in parts {
1122        if part.is_empty() {
1123            continue;
1124        }
1125        if let Some(idx) = text.find(part) {
1126            text = text.get(idx + part.len()..).unwrap_or_default();
1127        } else {
1128            return false;
1129        }
1130    }
1131    true
1132}
1133
1134impl RbacConfig {
1135    /// Applies `RMCP_SERVER_KIT__RBAC__*` environment overrides.
1136    ///
1137    /// Supports direct `redaction_salt` and `_FILE` secret indirection. Report
1138    /// entries for the secret target always redact the value. File-based
1139    /// secrets are treated as text: exactly one terminal line ending is removed
1140    /// (`\r\n`, `\n`, or `\r`) while other whitespace is preserved.
1141    ///
1142    /// # Errors
1143    ///
1144    /// Returns [`RmcpServerKitError::Config`] when both direct and file-based salt
1145    /// variables are set or when the `_FILE` target cannot be read.
1146    ///
1147    /// # Examples
1148    ///
1149    /// The full config-file pipeline lives in
1150    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
1151    ///
1152    /// ```no_run
1153    /// use rmcp_server_kit::rbac::RbacConfig;
1154    ///
1155    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1156    /// let mut rbac = RbacConfig::default();
1157    /// // Do not set process env in doctests: rustdoc examples share a process.
1158    /// let report = rbac.apply_env_overrides()?;
1159    /// let _secret_targets: Vec<&str> = report
1160    ///     .iter()
1161    ///     .filter(|entry| entry.value.is_none())
1162    ///     .map(|entry| entry.target_field.as_str())
1163    ///     .collect();
1164    /// # Ok(())
1165    /// # }
1166    /// ```
1167    pub fn apply_env_overrides(
1168        &mut self,
1169    ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1170        let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1171        let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1172        match (direct, file) {
1173            (None, None) => Ok(Vec::new()),
1174            (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1175                "{} and {} must not both be set",
1176                crate::config::RBAC_REDACTION_SALT_ENV,
1177                crate::config::RBAC_REDACTION_SALT_FILE_ENV
1178            ))),
1179            (Some(value), None) => {
1180                reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1181                self.redaction_salt = Some(SecretString::from(value));
1182                Ok(vec![crate::config::secret_env_report(
1183                    crate::config::RBAC_REDACTION_SALT_ENV,
1184                    "rbac.redaction_salt",
1185                    crate::config::EnvOverrideSource::Env,
1186                )])
1187            }
1188            (None, Some(path)) => {
1189                let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1190                    RmcpServerKitError::Config(format!(
1191                        "failed to read {} file {path:?}: {error}",
1192                        crate::config::RBAC_REDACTION_SALT_FILE_ENV
1193                    ))
1194                })?;
1195                let secret = normalize_text_secret_file(secret);
1196                reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1197                self.redaction_salt = Some(SecretString::from(secret));
1198                Ok(vec![crate::config::secret_env_report(
1199                    crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1200                    "rbac.redaction_salt",
1201                    crate::config::EnvOverrideSource::File,
1202                )])
1203            }
1204        }
1205    }
1206}
1207
1208fn normalize_text_secret_file(mut secret: String) -> String {
1209    if secret.ends_with("\r\n") {
1210        secret.truncate(secret.len() - 2);
1211    } else if secret.ends_with('\n') || secret.ends_with('\r') {
1212        secret.truncate(secret.len() - 1);
1213    }
1214    secret
1215}
1216
1217fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1218    if value.trim().is_empty() {
1219        return Err(RmcpServerKitError::Config(format!(
1220            "{env_var} must not be empty or whitespace-only"
1221        )));
1222    }
1223    Ok(())
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229
1230    fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1231        temp_env::with_vars(
1232            [
1233                (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1234                (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1235            ]
1236            .into_iter()
1237            .chain(vars.iter().copied())
1238            .collect::<Vec<_>>(),
1239            f,
1240        )
1241    }
1242
1243    #[test]
1244    fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1245        with_rbac_env(
1246            &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1247            || {
1248                let mut cfg = RbacConfig::default();
1249                let report = cfg.apply_env_overrides().unwrap();
1250                assert!(cfg.redaction_salt.is_some());
1251                assert_eq!(report.len(), 1);
1252                assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1253                assert_eq!(report[0].target_field, "rbac.redaction_salt");
1254                assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1255                assert!(report[0].value.is_none());
1256                assert!(!format!("{report:?}").contains("s3cret"));
1257            },
1258        );
1259    }
1260
1261    #[test]
1262    fn e7_redaction_salt_value_and_file_conflict_fails() {
1263        with_rbac_env(
1264            &[
1265                (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1266                (
1267                    crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1268                    Some("/tmp/secret-file"),
1269                ),
1270            ],
1271            || {
1272                let mut cfg = RbacConfig::default();
1273                let err = cfg.apply_env_overrides().unwrap_err();
1274                let msg = err.to_string();
1275                assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1276                assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1277            },
1278        );
1279    }
1280
1281    #[test]
1282    fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1283        let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1284        let direct_redaction = redaction_from_direct_salt("same-salt");
1285
1286        assert_eq!(file_redaction, direct_redaction);
1287        assert_eq!(report.len(), 1);
1288        assert_eq!(
1289            report[0].env_var,
1290            crate::config::RBAC_REDACTION_SALT_FILE_ENV
1291        );
1292        assert_eq!(report[0].target_field, "rbac.redaction_salt");
1293        assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1294        assert!(report[0].value.is_none());
1295    }
1296
1297    #[test]
1298    fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1299        let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1300        assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1301
1302        let (spaced_redaction, _) = redaction_from_file("  same-salt  \n").expect("spaced salt");
1303        assert_eq!(
1304            spaced_redaction,
1305            redaction_from_direct_salt("  same-salt  ")
1306        );
1307        assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1308    }
1309
1310    #[test]
1311    fn blank_redaction_salt_env_values_fail_closed() {
1312        for value in ["", "\n", "   "] {
1313            with_rbac_env(
1314                &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1315                || {
1316                    let mut cfg = RbacConfig::default();
1317                    let err = cfg.apply_env_overrides().unwrap_err();
1318                    assert!(
1319                        err.to_string()
1320                            .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1321                    );
1322                },
1323            );
1324        }
1325    }
1326
1327    #[test]
1328    fn blank_redaction_salt_file_values_fail_closed() {
1329        for value in ["", "\n", "\r\n", "   \n"] {
1330            let err = redaction_from_file(value).unwrap_err();
1331            assert!(
1332                err.to_string()
1333                    .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1334            );
1335        }
1336    }
1337
1338    fn redaction_from_direct_salt(salt: &str) -> String {
1339        RbacPolicy::new(&RbacConfig {
1340            redaction_salt: Some(SecretString::from(salt.to_owned())),
1341            ..RbacConfig::default()
1342        })
1343        .redact_arg("same-argument")
1344    }
1345
1346    fn redaction_from_file(
1347        content: &str,
1348    ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1349        let path = std::env::temp_dir().join(format!(
1350            "rmcp-server-kit-redaction-salt-{}.txt",
1351            std::time::SystemTime::now()
1352                .duration_since(std::time::UNIX_EPOCH)
1353                .expect("clock after epoch")
1354                .as_nanos()
1355        ));
1356        std::fs::write(&path, content).expect("write salt file");
1357        let path_string = path.to_string_lossy().to_string();
1358        let result = with_rbac_env(
1359            &[(
1360                crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1361                Some(path_string.as_str()),
1362            )],
1363            || {
1364                let mut cfg = RbacConfig::default();
1365                let report = cfg.apply_env_overrides()?;
1366                let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1367                Ok((redaction, report))
1368            },
1369        );
1370        std::fs::remove_file(path).expect("remove salt file");
1371        result
1372    }
1373
1374    // -- tool rate limiter: burst + Retry-After --
1375
1376    /// Burst capacity admits an initial spike larger than the sustained
1377    /// rate; the next request within the window is denied.
1378    #[test]
1379    fn tool_limiter_burst_allows_initial_spike() {
1380        let limiter = build_tool_rate_limiter(2, Some(4));
1381        let ip: IpAddr = "10.9.9.9".parse().unwrap();
1382        for i in 0..4 {
1383            assert!(
1384                limiter.check_key(&ip).is_ok(),
1385                "burst request {i} should pass"
1386            );
1387        }
1388        assert!(
1389            limiter.check_key(&ip).is_err(),
1390            "request 5 must exceed the burst bucket"
1391        );
1392    }
1393
1394    /// The tool-limiter deny response carries a Retry-After header.
1395    #[test]
1396    fn tool_limiter_deny_sets_retry_after() {
1397        let limiter = build_tool_rate_limiter(1, None);
1398        let ip: IpAddr = "10.8.8.8".parse().unwrap();
1399        assert!(enforce_rate_limit(Some(&limiter), Some(ip)).is_none());
1400        let resp = enforce_rate_limit(Some(&limiter), Some(ip))
1401            .expect("second call within the window must deny");
1402        assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1403        let retry_after = resp
1404            .headers()
1405            .get(axum::http::header::RETRY_AFTER)
1406            .expect("Retry-After present")
1407            .to_str()
1408            .unwrap()
1409            .parse::<u64>()
1410            .unwrap();
1411        assert!(retry_after >= 1, "delta-seconds must be >= 1");
1412    }
1413
1414    fn test_policy() -> RbacPolicy {
1415        RbacPolicy::new(&RbacConfig {
1416            enabled: true,
1417            roles: vec![
1418                RoleConfig {
1419                    name: "viewer".into(),
1420                    description: Some("Read-only".into()),
1421                    allow: vec![
1422                        "list_hosts".into(),
1423                        "resource_list".into(),
1424                        "resource_inspect".into(),
1425                        "resource_logs".into(),
1426                        "system_info".into(),
1427                    ],
1428                    deny: vec![],
1429                    hosts: vec!["*".into()],
1430                    argument_allowlists: vec![],
1431                },
1432                RoleConfig {
1433                    name: "deploy".into(),
1434                    description: Some("Lifecycle management".into()),
1435                    allow: vec![
1436                        "list_hosts".into(),
1437                        "resource_list".into(),
1438                        "resource_run".into(),
1439                        "resource_start".into(),
1440                        "resource_stop".into(),
1441                        "resource_restart".into(),
1442                        "resource_logs".into(),
1443                        "image_pull".into(),
1444                    ],
1445                    deny: vec!["resource_delete".into(), "resource_exec".into()],
1446                    hosts: vec!["web-*".into(), "api-*".into()],
1447                    argument_allowlists: vec![],
1448                },
1449                RoleConfig {
1450                    name: "ops".into(),
1451                    description: Some("Full access".into()),
1452                    allow: vec!["*".into()],
1453                    deny: vec![],
1454                    hosts: vec!["*".into()],
1455                    argument_allowlists: vec![],
1456                },
1457                RoleConfig {
1458                    name: "restricted-exec".into(),
1459                    description: Some("Exec with argument allowlist".into()),
1460                    allow: vec!["resource_exec".into()],
1461                    deny: vec![],
1462                    hosts: vec!["dev-*".into()],
1463                    argument_allowlists: vec![ArgumentAllowlist {
1464                        tool: "resource_exec".into(),
1465                        argument: "cmd".into(),
1466                        allowed: vec![
1467                            "sh".into(),
1468                            "bash".into(),
1469                            "cat".into(),
1470                            "ls".into(),
1471                            "ps".into(),
1472                        ],
1473                        required: false,
1474                    }],
1475                },
1476            ],
1477            redaction_salt: None,
1478        })
1479    }
1480
1481    // -- glob_match tests --
1482
1483    #[test]
1484    fn glob_exact_match() {
1485        assert!(glob_match("web-prod-1", "web-prod-1"));
1486        assert!(!glob_match("web-prod-1", "web-prod-2"));
1487    }
1488
1489    #[test]
1490    fn glob_star_suffix() {
1491        assert!(glob_match("web-*", "web-prod-1"));
1492        assert!(glob_match("web-*", "web-staging"));
1493        assert!(!glob_match("web-*", "api-prod"));
1494    }
1495
1496    #[test]
1497    fn glob_star_prefix() {
1498        assert!(glob_match("*-prod", "web-prod"));
1499        assert!(glob_match("*-prod", "api-prod"));
1500        assert!(!glob_match("*-prod", "web-staging"));
1501    }
1502
1503    #[test]
1504    fn glob_star_middle() {
1505        assert!(glob_match("web-*-prod", "web-us-prod"));
1506        assert!(glob_match("web-*-prod", "web-eu-east-prod"));
1507        assert!(!glob_match("web-*-prod", "web-staging"));
1508    }
1509
1510    #[test]
1511    fn glob_star_only() {
1512        assert!(glob_match("*", "anything"));
1513        assert!(glob_match("*", ""));
1514    }
1515
1516    #[test]
1517    fn glob_multiple_stars() {
1518        assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
1519        assert!(!glob_match("*web*prod*", "my-api-us-staging"));
1520    }
1521
1522    /// Pin char-boundary behavior of the `get(..)`-based slicing across
1523    /// multi-byte UTF-8 text: offsets derived from `starts_with` /
1524    /// `ends_with` / `find` are always boundary-aligned, and matching
1525    /// must behave identically to the ASCII cases.
1526    #[test]
1527    fn glob_match_multibyte_utf8() {
1528        assert!(glob_match("hé*llo", "héllo"));
1529        assert!(glob_match("*ö*", "wörld"));
1530        assert!(glob_match("über*", "übermensch"));
1531        assert!(glob_match("*界", "世界"));
1532        assert!(!glob_match("hé*llo", "hello"));
1533        assert!(!glob_match("界*", "世界"));
1534        assert!(glob_match("世*界", "世界"));
1535    }
1536
1537    // -- glob_match boundary / mutation-coverage tests --
1538    //
1539    // The cases below exist to kill specific mutants surfaced by
1540    // `cargo mutants` against `glob_match` / `match_middle` (see
1541    // CI run #84, May 2026). Each test is annotated with the mutation
1542    // it kills so the intent survives future refactors.
1543
1544    /// Kill: `if pos > end` mutated to `pos == end` and `pos >= end`
1545    /// at `glob_match` line 863. The prefix and suffix exactly meet
1546    /// (no characters between them); the original code accepts this,
1547    /// both mutants reject it.
1548    #[test]
1549    fn glob_prefix_and_suffix_meet_exactly() {
1550        // parts = ["ab", "cd"]; first.len()=2, end=text.len()-last.len()=2.
1551        // pos == end → original passes the `pos > end` check, mutants fail.
1552        assert!(glob_match("ab*cd", "abcd"));
1553    }
1554
1555    /// Kill: `parts.len() - 1` mutated to `parts.len() + 1` at line 868
1556    /// (middle-parts slice when pattern has a non-empty suffix). The
1557    /// mutant collapses the middle-parts slice to empty, which would
1558    /// incorrectly accept patterns whose middle segment isn't present.
1559    #[test]
1560    fn glob_middle_segment_required_with_suffix() {
1561        // Pattern requires "b" between "a" and "c"; text omits it.
1562        // Original: middle_parts=["b"], match_middle("xy", ["b"])=false → reject.
1563        // Mutant `+`: middle_parts=[] (slice out of bounds → unwrap_or_default),
1564        //             match_middle("xy", [])=true → wrongly accept.
1565        assert!(!glob_match("a*b*c", "axyc"));
1566    }
1567
1568    /// Kill: `idx + part.len()` mutated to `idx - part.len()` at
1569    /// `match_middle` line 885. The mutant either underflows
1570    /// (panic in test) or fails to advance past the matched part,
1571    /// causing it to re-find the same prefix and accept patterns
1572    /// that should be rejected.
1573    #[test]
1574    fn glob_match_middle_advances_past_matched_part() {
1575        // Original: after finding "ab" at idx 2, advance to text[4..]="_yz",
1576        //           which contains no second "ab" → reject.
1577        // Mutant `-`: text[2-2..]="xxab_yz" → re-finds "ab" → wrongly accept
1578        //             (or panics for the smaller-idx variants).
1579        assert!(!glob_match("*ab*ab*", "xxab_yz"));
1580    }
1581
1582    /// Kill: `idx + part.len()` mutated to `idx * part.len()` at
1583    /// `match_middle` line 885. The mutant computes a different
1584    /// (usually larger) advance offset that produces an out-of-bounds
1585    /// slice and panics, or skips over content that should match.
1586    #[test]
1587    fn glob_match_middle_uses_addition_not_multiplication() {
1588        // Original: find "abcde" at idx 8 in "yyyyyyyyabcde_X", advance
1589        //           to text[13..]="_X", find "X" → accept.
1590        // Mutant `*`: text[8*5..]=text[40..] → out-of-bounds → panic.
1591        assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
1592    }
1593
1594    // -- RbacPolicy::argument_allowed mutation-coverage tests --
1595
1596    /// Kill: `&&` mutated to `||` at `argument_allowed` line 494.
1597    /// The original short-circuits the allowlist lookup only when both
1598    /// the literal name AND the glob fail to match. The mutant
1599    /// short-circuits when EITHER fails, which means a glob-matched
1600    /// allowlist (literal mismatch, glob match) is silently skipped
1601    /// and the call is wrongly allowed.
1602    #[test]
1603    fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
1604        // Allowlist registered against pattern "run-*" with allowed=["ls"].
1605        // Calling tool="run-foo" — literal "run-*" != "run-foo" (true),
1606        // but glob_match("run-*", "run-foo") = true.
1607        //   Original `&&`: skip-condition = true && false = false → enforce
1608        //                  allowlist → "rm" not in ["ls"] → deny.
1609        //   Mutant `||`:   skip-condition = true || false = true → skip
1610        //                  allowlist → wrongly allow.
1611        let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
1612            .with_argument_allowlists(vec![ArgumentAllowlist::new(
1613                "run-*",
1614                "cmd",
1615                vec!["ls".into()],
1616            )]);
1617        let mut config = RbacConfig::with_roles(vec![role]);
1618        config.enabled = true;
1619        let policy = RbacPolicy::new(&config);
1620        assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
1621    }
1622
1623    // -- RbacPolicy::check tests --
1624
1625    #[test]
1626    fn disabled_policy_allows_everything() {
1627        let policy = RbacPolicy::new(&RbacConfig {
1628            enabled: false,
1629            roles: vec![],
1630            redaction_salt: None,
1631        });
1632        assert_eq!(
1633            policy.check("nonexistent", "resource_delete", "any-host"),
1634            RbacDecision::Allow
1635        );
1636    }
1637
1638    #[test]
1639    fn unknown_role_denied() {
1640        let policy = test_policy();
1641        assert_eq!(
1642            policy.check("unknown", "resource_list", "web-prod-1"),
1643            RbacDecision::Deny
1644        );
1645    }
1646
1647    #[test]
1648    fn viewer_allowed_read_ops() {
1649        let policy = test_policy();
1650        assert_eq!(
1651            policy.check("viewer", "resource_list", "web-prod-1"),
1652            RbacDecision::Allow
1653        );
1654        assert_eq!(
1655            policy.check("viewer", "system_info", "db-host"),
1656            RbacDecision::Allow
1657        );
1658    }
1659
1660    #[test]
1661    fn viewer_denied_write_ops() {
1662        let policy = test_policy();
1663        assert_eq!(
1664            policy.check("viewer", "resource_run", "web-prod-1"),
1665            RbacDecision::Deny
1666        );
1667        assert_eq!(
1668            policy.check("viewer", "resource_delete", "web-prod-1"),
1669            RbacDecision::Deny
1670        );
1671    }
1672
1673    #[test]
1674    fn deploy_allowed_on_matching_hosts() {
1675        let policy = test_policy();
1676        assert_eq!(
1677            policy.check("deploy", "resource_run", "web-prod-1"),
1678            RbacDecision::Allow
1679        );
1680        assert_eq!(
1681            policy.check("deploy", "resource_start", "api-staging"),
1682            RbacDecision::Allow
1683        );
1684    }
1685
1686    #[test]
1687    fn deploy_denied_on_non_matching_host() {
1688        let policy = test_policy();
1689        assert_eq!(
1690            policy.check("deploy", "resource_run", "db-prod-1"),
1691            RbacDecision::Deny
1692        );
1693    }
1694
1695    #[test]
1696    fn deny_overrides_allow() {
1697        let policy = test_policy();
1698        assert_eq!(
1699            policy.check("deploy", "resource_delete", "web-prod-1"),
1700            RbacDecision::Deny
1701        );
1702        assert_eq!(
1703            policy.check("deploy", "resource_exec", "web-prod-1"),
1704            RbacDecision::Deny
1705        );
1706    }
1707
1708    #[test]
1709    fn ops_wildcard_allows_everything() {
1710        let policy = test_policy();
1711        assert_eq!(
1712            policy.check("ops", "resource_delete", "any-host"),
1713            RbacDecision::Allow
1714        );
1715        assert_eq!(
1716            policy.check("ops", "secret_create", "db-host"),
1717            RbacDecision::Allow
1718        );
1719    }
1720
1721    // -- host_visible tests --
1722
1723    #[test]
1724    fn host_visible_respects_globs() {
1725        let policy = test_policy();
1726        assert!(policy.host_visible("deploy", "web-prod-1"));
1727        assert!(policy.host_visible("deploy", "api-staging"));
1728        assert!(!policy.host_visible("deploy", "db-prod-1"));
1729        assert!(policy.host_visible("ops", "anything"));
1730        assert!(policy.host_visible("viewer", "anything"));
1731    }
1732
1733    #[test]
1734    fn host_visible_unknown_role() {
1735        let policy = test_policy();
1736        assert!(!policy.host_visible("unknown", "web-prod-1"));
1737    }
1738
1739    // -- argument_allowed tests --
1740
1741    #[test]
1742    fn argument_allowed_no_allowlist() {
1743        let policy = test_policy();
1744        // ops has no argument_allowlists -- all values allowed
1745        assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
1746        assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
1747    }
1748
1749    #[test]
1750    fn argument_allowed_with_allowlist() {
1751        let policy = test_policy();
1752        assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
1753        assert!(policy.argument_allowed(
1754            "restricted-exec",
1755            "resource_exec",
1756            "cmd",
1757            "bash -c 'echo hi'"
1758        ));
1759        assert!(policy.argument_allowed(
1760            "restricted-exec",
1761            "resource_exec",
1762            "cmd",
1763            "cat /etc/hosts"
1764        ));
1765        assert!(policy.argument_allowed(
1766            "restricted-exec",
1767            "resource_exec",
1768            "cmd",
1769            "/usr/bin/ls -la"
1770        ));
1771    }
1772
1773    #[test]
1774    fn argument_denied_not_in_allowlist() {
1775        let policy = test_policy();
1776        assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
1777        assert!(!policy.argument_allowed(
1778            "restricted-exec",
1779            "resource_exec",
1780            "cmd",
1781            "python3 exploit.py"
1782        ));
1783        assert!(!policy.argument_allowed(
1784            "restricted-exec",
1785            "resource_exec",
1786            "cmd",
1787            "/usr/bin/curl evil.com"
1788        ));
1789    }
1790
1791    #[test]
1792    fn argument_denied_unknown_role() {
1793        let policy = test_policy();
1794        assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
1795    }
1796
1797    // -- shlex-tokenization regression tests (1.4.1) --
1798    //
1799    // These tests pin the POSIX-shell-like tokenization contract added
1800    // in 1.4.1. See `RbacPolicy::argument_allowed` doc comment for the
1801    // full contract; see CHANGELOG.md `[1.4.1]` for the behavior matrix.
1802
1803    /// Helper: build a minimal enabled policy with a single argument
1804    /// allowlist on tool `run`, argument `cmd`.
1805    fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
1806        let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1807            .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
1808        let mut config = RbacConfig::with_roles(vec![role]);
1809        config.enabled = true;
1810        RbacPolicy::new(&config)
1811    }
1812
1813    #[test]
1814    fn argument_allowed_matches_quoted_path_with_spaces() {
1815        let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
1816        assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1817    }
1818
1819    #[test]
1820    fn argument_allowed_matches_basename_of_quoted_path() {
1821        let policy = shlex_policy(vec!["my tool".into()]);
1822        assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
1823    }
1824
1825    #[test]
1826    fn argument_allowed_fails_closed_on_unbalanced_quote() {
1827        let policy = shlex_policy(vec!["unbalanced".into()]);
1828        assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
1829    }
1830
1831    #[test]
1832    fn argument_allowed_fails_closed_on_empty_string() {
1833        let policy = shlex_policy(vec![String::new()]);
1834        assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
1835    }
1836
1837    #[test]
1838    fn argument_allowed_handles_single_quoted_executable() {
1839        let policy = shlex_policy(vec!["/bin/sh".into()]);
1840        assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
1841    }
1842
1843    #[test]
1844    fn argument_allowed_handles_tab_separator() {
1845        let policy = shlex_policy(vec!["ls".into()]);
1846        assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
1847    }
1848
1849    #[test]
1850    fn argument_allowed_plain_token_unchanged() {
1851        let policy = shlex_policy(vec!["ls".into()]);
1852        assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
1853    }
1854
1855    // Per Oracle review: the next four tests pin the cases the original
1856    // handoff missed. Each confirms the *new* (1.4.1) deny behavior so a
1857    // future regression to the old `split_whitespace` semantics would
1858    // surface as a test failure.
1859
1860    #[test]
1861    fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
1862        // value r#""""# parses to Some(vec![""]). An empty argv element
1863        // is never a runnable executable; deny even when "" is
1864        // explicitly allowlisted.
1865        let policy = shlex_policy(vec![String::new()]);
1866        assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
1867    }
1868
1869    #[test]
1870    fn argument_allowed_quoted_literal_token_no_longer_matches() {
1871        // 1.4.0 behavior: split_whitespace first token = "'bash'" --
1872        //                 matched literal allowlist entry "'bash'".
1873        // 1.4.1 behavior: shlex strips the surrounding quotes -> first
1874        //                 token = "bash" -- no match against allowlist
1875        //                 entry "'bash'". Deny.
1876        let policy = shlex_policy(vec!["'bash'".into()]);
1877        assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
1878    }
1879
1880    #[test]
1881    fn argument_allowed_backslash_literal_token_no_longer_matches() {
1882        // 1.4.0 behavior: literal first token "foo\\bar" matched.
1883        // 1.4.1 behavior: POSIX shlex treats backslash as escape ->
1884        //                 first token = "foobar". Allowlist entry with
1885        //                 a literal backslash no longer matches. Deny.
1886        let policy = shlex_policy(vec![r"foo\bar".into()]);
1887        assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
1888    }
1889
1890    #[test]
1891    fn argument_allowed_windows_path_no_longer_matches() {
1892        // 1.4.0 behavior: literal Windows path matched.
1893        // 1.4.1 behavior: POSIX shlex eats backslashes -> path identity
1894        //                 changes; allowlist entry no longer matches.
1895        //                 Deny. Documented in CHANGELOG operator notes.
1896        let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
1897        assert!(!policy.argument_allowed(
1898            "viewer",
1899            "run",
1900            "cmd",
1901            r"C:\Windows\System32\cmd.exe /c dir"
1902        ));
1903    }
1904
1905    // -- host_patterns tests --
1906
1907    #[test]
1908    fn host_patterns_returns_globs() {
1909        let policy = test_policy();
1910        assert_eq!(
1911            policy.host_patterns("deploy"),
1912            Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
1913        );
1914        assert_eq!(
1915            policy.host_patterns("ops"),
1916            Some(vec!["*".to_owned()].as_slice())
1917        );
1918        assert!(policy.host_patterns("nonexistent").is_none());
1919    }
1920
1921    // -- check_operation tests (no host check) --
1922
1923    #[test]
1924    fn check_operation_allows_without_host() {
1925        let policy = test_policy();
1926        assert_eq!(
1927            policy.check_operation("deploy", "resource_run"),
1928            RbacDecision::Allow
1929        );
1930        // but check() with a non-matching host denies
1931        assert_eq!(
1932            policy.check("deploy", "resource_run", "db-prod-1"),
1933            RbacDecision::Deny
1934        );
1935    }
1936
1937    #[test]
1938    fn check_operation_deny_overrides() {
1939        let policy = test_policy();
1940        assert_eq!(
1941            policy.check_operation("deploy", "resource_delete"),
1942            RbacDecision::Deny
1943        );
1944    }
1945
1946    #[test]
1947    fn check_operation_unknown_role() {
1948        let policy = test_policy();
1949        assert_eq!(
1950            policy.check_operation("unknown", "resource_list"),
1951            RbacDecision::Deny
1952        );
1953    }
1954
1955    #[test]
1956    fn check_operation_disabled() {
1957        let policy = RbacPolicy::new(&RbacConfig {
1958            enabled: false,
1959            roles: vec![],
1960            redaction_salt: None,
1961        });
1962        assert_eq!(
1963            policy.check_operation("nonexistent", "anything"),
1964            RbacDecision::Allow
1965        );
1966    }
1967
1968    // -- current_role / current_identity tests --
1969
1970    #[test]
1971    fn current_role_returns_none_outside_scope() {
1972        assert!(current_role().is_none());
1973    }
1974
1975    #[test]
1976    fn current_identity_returns_none_outside_scope() {
1977        assert!(current_identity().is_none());
1978    }
1979
1980    // -- rbac_middleware integration tests --
1981
1982    use axum::{
1983        body::Body,
1984        http::{Method, Request, StatusCode},
1985    };
1986    use tower::ServiceExt as _;
1987
1988    fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
1989        serde_json::json!({
1990            "jsonrpc": "2.0",
1991            "id": 1,
1992            "method": "tools/call",
1993            "params": {
1994                "name": tool,
1995                "arguments": args
1996            }
1997        })
1998        .to_string()
1999    }
2000
2001    fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
2002        axum::Router::new()
2003            .route("/mcp", axum::routing::post(|| async { "ok" }))
2004            .layer(axum::middleware::from_fn(move |req, next| {
2005                let p = Arc::clone(&policy);
2006                rbac_middleware(p, None, req, next)
2007            }))
2008    }
2009
2010    fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
2011        axum::Router::new()
2012            .route("/mcp", axum::routing::post(|| async { "ok" }))
2013            .layer(axum::middleware::from_fn(
2014                move |mut req: Request<Body>, next: Next| {
2015                    let p = Arc::clone(&policy);
2016                    let id = identity.clone();
2017                    async move {
2018                        req.extensions_mut().insert(id);
2019                        rbac_middleware(p, None, req, next).await
2020                    }
2021                },
2022            ))
2023    }
2024
2025    /// Tool-limiter deny path must increment the `tool` deny counter via
2026    /// the metrics handle in the request extensions — and the increment
2027    /// must survive the middleware's body-buffer/`from_parts` rebuild.
2028    #[cfg(feature = "metrics")]
2029    #[tokio::test]
2030    async fn tool_limiter_deny_increments_counter() {
2031        use axum::extract::ConnectInfo;
2032
2033        let policy = Arc::new(test_policy());
2034        let limiter = build_tool_rate_limiter(1, None);
2035        let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
2036        let identity = AuthIdentity {
2037            method: crate::auth::AuthMethod::BearerToken,
2038            name: "alice".into(),
2039            role: "viewer".into(),
2040            raw_token: None,
2041            sub: None,
2042        };
2043        let app = {
2044            let metrics = Arc::clone(&metrics);
2045            axum::Router::new()
2046                .route("/mcp", axum::routing::post(|| async { "ok" }))
2047                .layer(axum::middleware::from_fn(
2048                    move |mut req: Request<Body>, next: Next| {
2049                        let p = Arc::clone(&policy);
2050                        let l = Arc::clone(&limiter);
2051                        let id = identity.clone();
2052                        let m = Arc::clone(&metrics);
2053                        async move {
2054                            req.extensions_mut().insert(id);
2055                            req.extensions_mut().insert(m);
2056                            let peer: std::net::SocketAddr =
2057                                "10.9.9.1:40000".parse().expect("static socket addr parses");
2058                            req.extensions_mut().insert(ConnectInfo(peer));
2059                            rbac_middleware(p, Some(l), req, next).await
2060                        }
2061                    },
2062                ))
2063        };
2064        let mk = || {
2065            Request::builder()
2066                .method(Method::POST)
2067                .uri("/mcp")
2068                .header("content-type", "application/json")
2069                .body(Body::from(tool_call_body(
2070                    "resource_list",
2071                    &serde_json::json!({}),
2072                )))
2073                .unwrap()
2074        };
2075        let counter = || {
2076            metrics
2077                .rate_limited_total
2078                .with_label_values(&["tool"])
2079                .get()
2080        };
2081
2082        let first = app.clone().oneshot(mk()).await.unwrap();
2083        assert_eq!(first.status(), StatusCode::OK);
2084        assert_eq!(counter(), 0, "successful call must not count");
2085
2086        let denied = app.clone().oneshot(mk()).await.unwrap();
2087        assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
2088        assert_eq!(counter(), 1, "deny must increment the tool label");
2089    }
2090
2091    #[tokio::test]
2092    async fn middleware_passes_non_post() {
2093        let policy = Arc::new(test_policy());
2094        let app = rbac_router(policy);
2095        // GET passes through even without identity.
2096        let req = Request::builder()
2097            .method(Method::GET)
2098            .uri("/mcp")
2099            .body(Body::empty())
2100            .unwrap();
2101        // GET on a POST-only route returns 405, but the middleware itself
2102        // doesn't block it -- it returns next.run(req).
2103        let resp = app.oneshot(req).await.unwrap();
2104        assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
2105    }
2106
2107    #[tokio::test]
2108    async fn middleware_denies_without_identity() {
2109        let policy = Arc::new(test_policy());
2110        let app = rbac_router(policy);
2111        let body = tool_call_body("resource_list", &serde_json::json!({}));
2112        let req = Request::builder()
2113            .method(Method::POST)
2114            .uri("/mcp")
2115            .header("content-type", "application/json")
2116            .body(Body::from(body))
2117            .unwrap();
2118        let resp = app.oneshot(req).await.unwrap();
2119        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2120    }
2121
2122    #[tokio::test]
2123    async fn middleware_allows_permitted_tool() {
2124        let policy = Arc::new(test_policy());
2125        let id = AuthIdentity {
2126            method: crate::auth::AuthMethod::BearerToken,
2127            name: "alice".into(),
2128            role: "viewer".into(),
2129            raw_token: None,
2130            sub: None,
2131        };
2132        let app = rbac_router_with_identity(policy, id);
2133        let body = tool_call_body("resource_list", &serde_json::json!({}));
2134        let req = Request::builder()
2135            .method(Method::POST)
2136            .uri("/mcp")
2137            .header("content-type", "application/json")
2138            .body(Body::from(body))
2139            .unwrap();
2140        let resp = app.oneshot(req).await.unwrap();
2141        assert_eq!(resp.status(), StatusCode::OK);
2142    }
2143
2144    #[tokio::test]
2145    async fn middleware_denies_unpermitted_tool() {
2146        let policy = Arc::new(test_policy());
2147        let id = AuthIdentity {
2148            method: crate::auth::AuthMethod::BearerToken,
2149            name: "alice".into(),
2150            role: "viewer".into(),
2151            raw_token: None,
2152            sub: None,
2153        };
2154        let app = rbac_router_with_identity(policy, id);
2155        let body = tool_call_body("resource_delete", &serde_json::json!({}));
2156        let req = Request::builder()
2157            .method(Method::POST)
2158            .uri("/mcp")
2159            .header("content-type", "application/json")
2160            .body(Body::from(body))
2161            .unwrap();
2162        let resp = app.oneshot(req).await.unwrap();
2163        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2164    }
2165
2166    #[tokio::test]
2167    async fn middleware_passes_non_tool_call_post() {
2168        let policy = Arc::new(test_policy());
2169        let id = AuthIdentity {
2170            method: crate::auth::AuthMethod::BearerToken,
2171            name: "alice".into(),
2172            role: "viewer".into(),
2173            raw_token: None,
2174            sub: None,
2175        };
2176        let app = rbac_router_with_identity(policy, id);
2177        // A non-tools/call JSON-RPC (e.g. resources/list) passes through.
2178        let body = serde_json::json!({
2179            "jsonrpc": "2.0",
2180            "id": 1,
2181            "method": "resources/list"
2182        })
2183        .to_string();
2184        let req = Request::builder()
2185            .method(Method::POST)
2186            .uri("/mcp")
2187            .header("content-type", "application/json")
2188            .body(Body::from(body))
2189            .unwrap();
2190        let resp = app.oneshot(req).await.unwrap();
2191        assert_eq!(resp.status(), StatusCode::OK);
2192    }
2193
2194    #[tokio::test]
2195    async fn middleware_enforces_argument_allowlist() {
2196        let policy = Arc::new(test_policy());
2197        let id = AuthIdentity {
2198            method: crate::auth::AuthMethod::BearerToken,
2199            name: "dev".into(),
2200            role: "restricted-exec".into(),
2201            raw_token: None,
2202            sub: None,
2203        };
2204        // Allowed command
2205        let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
2206        let body = tool_call_body(
2207            "resource_exec",
2208            &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
2209        );
2210        let req = Request::builder()
2211            .method(Method::POST)
2212            .uri("/mcp")
2213            .body(Body::from(body))
2214            .unwrap();
2215        let resp = app.oneshot(req).await.unwrap();
2216        assert_eq!(resp.status(), StatusCode::OK);
2217
2218        // Denied command
2219        let app = rbac_router_with_identity(policy, id);
2220        let body = tool_call_body(
2221            "resource_exec",
2222            &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
2223        );
2224        let req = Request::builder()
2225            .method(Method::POST)
2226            .uri("/mcp")
2227            .body(Body::from(body))
2228            .unwrap();
2229        let resp = app.oneshot(req).await.unwrap();
2230        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2231    }
2232
2233    #[tokio::test]
2234    async fn middleware_disabled_policy_passes_everything() {
2235        let policy = Arc::new(RbacPolicy::disabled());
2236        let app = rbac_router(policy);
2237        // No identity, disabled policy -- should pass.
2238        let body = tool_call_body("anything", &serde_json::json!({}));
2239        let req = Request::builder()
2240            .method(Method::POST)
2241            .uri("/mcp")
2242            .body(Body::from(body))
2243            .unwrap();
2244        let resp = app.oneshot(req).await.unwrap();
2245        assert_eq!(resp.status(), StatusCode::OK);
2246    }
2247
2248    #[tokio::test]
2249    async fn middleware_batch_all_allowed_passes() {
2250        let policy = Arc::new(test_policy());
2251        let id = AuthIdentity {
2252            method: crate::auth::AuthMethod::BearerToken,
2253            name: "alice".into(),
2254            role: "viewer".into(),
2255            raw_token: None,
2256            sub: None,
2257        };
2258        let app = rbac_router_with_identity(policy, id);
2259        let body = serde_json::json!([
2260            {
2261                "jsonrpc": "2.0",
2262                "id": 1,
2263                "method": "tools/call",
2264                "params": { "name": "resource_list", "arguments": {} }
2265            },
2266            {
2267                "jsonrpc": "2.0",
2268                "id": 2,
2269                "method": "tools/call",
2270                "params": { "name": "system_info", "arguments": {} }
2271            }
2272        ])
2273        .to_string();
2274        let req = Request::builder()
2275            .method(Method::POST)
2276            .uri("/mcp")
2277            .header("content-type", "application/json")
2278            .body(Body::from(body))
2279            .unwrap();
2280        let resp = app.oneshot(req).await.unwrap();
2281        assert_eq!(resp.status(), StatusCode::OK);
2282    }
2283
2284    #[tokio::test]
2285    async fn middleware_batch_with_denied_call_rejects_entire_batch() {
2286        let policy = Arc::new(test_policy());
2287        let id = AuthIdentity {
2288            method: crate::auth::AuthMethod::BearerToken,
2289            name: "alice".into(),
2290            role: "viewer".into(),
2291            raw_token: None,
2292            sub: None,
2293        };
2294        let app = rbac_router_with_identity(policy, id);
2295        let body = serde_json::json!([
2296            {
2297                "jsonrpc": "2.0",
2298                "id": 1,
2299                "method": "tools/call",
2300                "params": { "name": "resource_list", "arguments": {} }
2301            },
2302            {
2303                "jsonrpc": "2.0",
2304                "id": 2,
2305                "method": "tools/call",
2306                "params": { "name": "resource_delete", "arguments": {} }
2307            }
2308        ])
2309        .to_string();
2310        let req = Request::builder()
2311            .method(Method::POST)
2312            .uri("/mcp")
2313            .header("content-type", "application/json")
2314            .body(Body::from(body))
2315            .unwrap();
2316        let resp = app.oneshot(req).await.unwrap();
2317        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2318    }
2319
2320    #[tokio::test]
2321    async fn middleware_batch_mixed_allowed_and_denied_rejects() {
2322        let policy = Arc::new(test_policy());
2323        let id = AuthIdentity {
2324            method: crate::auth::AuthMethod::BearerToken,
2325            name: "dev".into(),
2326            role: "restricted-exec".into(),
2327            raw_token: None,
2328            sub: None,
2329        };
2330        let app = rbac_router_with_identity(policy, id);
2331        let body = serde_json::json!([
2332            {
2333                "jsonrpc": "2.0",
2334                "id": 1,
2335                "method": "tools/call",
2336                "params": {
2337                    "name": "resource_exec",
2338                    "arguments": { "cmd": "ls -la", "host": "dev-1" }
2339                }
2340            },
2341            {
2342                "jsonrpc": "2.0",
2343                "id": 2,
2344                "method": "tools/call",
2345                "params": {
2346                    "name": "resource_exec",
2347                    "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
2348                }
2349            }
2350        ])
2351        .to_string();
2352        let req = Request::builder()
2353            .method(Method::POST)
2354            .uri("/mcp")
2355            .header("content-type", "application/json")
2356            .body(Body::from(body))
2357            .unwrap();
2358        let resp = app.oneshot(req).await.unwrap();
2359        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2360    }
2361
2362    // -- redact_arg / redaction_salt tests --
2363
2364    #[test]
2365    fn redact_with_salt_is_deterministic_per_salt() {
2366        let salt = b"unit-test-salt";
2367        let a = redact_with_salt(salt, "rm -rf /");
2368        let b = redact_with_salt(salt, "rm -rf /");
2369        assert_eq!(a, b, "same input + salt must yield identical hash");
2370        assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
2371        assert!(
2372            a.chars().all(|c| c.is_ascii_hexdigit()),
2373            "redacted hash must be lowercase hex: {a}"
2374        );
2375    }
2376
2377    #[test]
2378    fn redact_with_salt_differs_across_salts() {
2379        let v = "the-same-value";
2380        let h1 = redact_with_salt(b"salt-one", v);
2381        let h2 = redact_with_salt(b"salt-two", v);
2382        assert_ne!(
2383            h1, h2,
2384            "different salts must produce different hashes for the same value"
2385        );
2386    }
2387
2388    #[test]
2389    fn redact_with_salt_distinguishes_values() {
2390        let salt = b"k";
2391        let h1 = redact_with_salt(salt, "alpha");
2392        let h2 = redact_with_salt(salt, "beta");
2393        // Hash collisions on 32 bits are 1-in-4-billion; safe to assert.
2394        assert_ne!(h1, h2, "different values must produce different hashes");
2395    }
2396
2397    #[test]
2398    fn policy_with_configured_salt_redacts_consistently() {
2399        let cfg = RbacConfig {
2400            enabled: true,
2401            roles: vec![],
2402            redaction_salt: Some(SecretString::from("my-stable-salt")),
2403        };
2404        let p1 = RbacPolicy::new(&cfg);
2405        let p2 = RbacPolicy::new(&cfg);
2406        assert_eq!(
2407            p1.redact_arg("payload"),
2408            p2.redact_arg("payload"),
2409            "policies built from the same configured salt must agree"
2410        );
2411    }
2412
2413    #[test]
2414    fn policy_without_configured_salt_uses_process_salt() {
2415        let cfg = RbacConfig {
2416            enabled: true,
2417            roles: vec![],
2418            redaction_salt: None,
2419        };
2420        let p1 = RbacPolicy::new(&cfg);
2421        let p2 = RbacPolicy::new(&cfg);
2422        // Within one process, the lazy OnceLock salt is shared.
2423        assert_eq!(
2424            p1.redact_arg("payload"),
2425            p2.redact_arg("payload"),
2426            "process-wide salt must be consistent within one process"
2427        );
2428    }
2429
2430    #[test]
2431    fn redact_arg_is_fast_enough() {
2432        // Sanity floor: a single redaction should take well under 100 µs
2433        // even in unoptimized debug builds. Production criterion bench
2434        // (see H-T4 plan) will assert a stricter <10 µs threshold.
2435        let salt = b"perf-sanity-salt-32-bytes-padded";
2436        let value = "x".repeat(256);
2437        let start = std::time::Instant::now();
2438        let _ = redact_with_salt(salt, &value);
2439        let elapsed = start.elapsed();
2440        assert!(
2441            elapsed < Duration::from_millis(5),
2442            "single redact_with_salt took {elapsed:?}, expected <5 ms even in debug"
2443        );
2444    }
2445
2446    // -- enforce_tool_policy identity propagation regression test (BUG H-S3) --
2447
2448    /// Regression: when `enforce_tool_policy` denied a request, the deny
2449    /// log used to read `current_identity()`, which was always `None` at
2450    /// that point because the task-local context is installed *after*
2451    /// policy enforcement. The fix passes `identity_name` explicitly.
2452    ///
2453    /// We assert the deny path returns 403 (the visible behaviour).
2454    /// The log-content assertion lives behind tracing-test which we have
2455    /// not yet added as a dev-dep; the explicit-parameter signature alone
2456    /// makes the previous bug structurally impossible.
2457    #[tokio::test]
2458    async fn deny_path_uses_explicit_identity_not_task_local() {
2459        let policy = Arc::new(test_policy());
2460        let id = AuthIdentity {
2461            method: crate::auth::AuthMethod::BearerToken,
2462            name: "alice-the-auditor".into(),
2463            role: "viewer".into(),
2464            raw_token: None,
2465            sub: None,
2466        };
2467        let app = rbac_router_with_identity(policy, id);
2468        // viewer is not allowed to call resource_delete -> 403.
2469        let body = tool_call_body("resource_delete", &serde_json::json!({}));
2470        let req = Request::builder()
2471            .method(Method::POST)
2472            .uri("/mcp")
2473            .header("content-type", "application/json")
2474            .body(Body::from(body))
2475            .unwrap();
2476        let resp = app.oneshot(req).await.unwrap();
2477        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2478    }
2479
2480    // -- M2 regression: non-string argument values bypass allowlist --
2481
2482    fn restricted_exec_identity() -> AuthIdentity {
2483        AuthIdentity {
2484            method: crate::auth::AuthMethod::BearerToken,
2485            name: "carol".into(),
2486            role: "restricted-exec".into(),
2487            raw_token: None,
2488            sub: None,
2489        }
2490    }
2491
2492    #[test]
2493    fn has_argument_allowlist_matches_configured_tool_argument() {
2494        let policy = test_policy();
2495        assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
2496        assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
2497        assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
2498        assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
2499    }
2500
2501    #[tokio::test]
2502    async fn array_arg_with_matching_allowlist_is_denied() {
2503        let policy = Arc::new(test_policy());
2504        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2505        let body = tool_call_body(
2506            "resource_exec",
2507            &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
2508        );
2509        let req = Request::builder()
2510            .method(Method::POST)
2511            .uri("/mcp")
2512            .header("content-type", "application/json")
2513            .body(Body::from(body))
2514            .unwrap();
2515        let resp = app.oneshot(req).await.unwrap();
2516        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2517    }
2518
2519    #[tokio::test]
2520    async fn object_arg_with_matching_allowlist_is_denied() {
2521        let policy = Arc::new(test_policy());
2522        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2523        let body = tool_call_body(
2524            "resource_exec",
2525            &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
2526        );
2527        let req = Request::builder()
2528            .method(Method::POST)
2529            .uri("/mcp")
2530            .header("content-type", "application/json")
2531            .body(Body::from(body))
2532            .unwrap();
2533        let resp = app.oneshot(req).await.unwrap();
2534        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2535    }
2536
2537    #[tokio::test]
2538    async fn number_arg_with_matching_allowlist_is_denied() {
2539        let policy = Arc::new(test_policy());
2540        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2541        let body = tool_call_body(
2542            "resource_exec",
2543            &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
2544        );
2545        let req = Request::builder()
2546            .method(Method::POST)
2547            .uri("/mcp")
2548            .header("content-type", "application/json")
2549            .body(Body::from(body))
2550            .unwrap();
2551        let resp = app.oneshot(req).await.unwrap();
2552        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2553    }
2554
2555    #[tokio::test]
2556    async fn bool_arg_with_matching_allowlist_is_denied() {
2557        let policy = Arc::new(test_policy());
2558        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2559        let body = tool_call_body(
2560            "resource_exec",
2561            &serde_json::json!({ "host": "dev-1", "cmd": true }),
2562        );
2563        let req = Request::builder()
2564            .method(Method::POST)
2565            .uri("/mcp")
2566            .header("content-type", "application/json")
2567            .body(Body::from(body))
2568            .unwrap();
2569        let resp = app.oneshot(req).await.unwrap();
2570        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2571    }
2572
2573    #[tokio::test]
2574    async fn null_arg_with_matching_allowlist_is_denied() {
2575        let policy = Arc::new(test_policy());
2576        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2577        let body = tool_call_body(
2578            "resource_exec",
2579            &serde_json::json!({ "host": "dev-1", "cmd": null }),
2580        );
2581        let req = Request::builder()
2582            .method(Method::POST)
2583            .uri("/mcp")
2584            .header("content-type", "application/json")
2585            .body(Body::from(body))
2586            .unwrap();
2587        let resp = app.oneshot(req).await.unwrap();
2588        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
2589    }
2590
2591    #[tokio::test]
2592    async fn non_string_arg_without_allowlist_is_passthrough() {
2593        // ops has no argument_allowlist for any (tool, arg) tuple, so
2594        // non-string values must reach the handler. resource_exec is in
2595        // ops's allow list so the call should not be rejected by RBAC.
2596        let policy = Arc::new(test_policy());
2597        let id = AuthIdentity {
2598            method: crate::auth::AuthMethod::BearerToken,
2599            name: "olivia".into(),
2600            role: "ops".into(),
2601            raw_token: None,
2602            sub: None,
2603        };
2604        let app = rbac_router_with_identity(policy, id);
2605        let body = tool_call_body(
2606            "resource_exec",
2607            &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
2608        );
2609        let req = Request::builder()
2610            .method(Method::POST)
2611            .uri("/mcp")
2612            .header("content-type", "application/json")
2613            .body(Body::from(body))
2614            .unwrap();
2615        let resp = app.oneshot(req).await.unwrap();
2616        assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2617    }
2618
2619    #[tokio::test]
2620    async fn string_arg_in_allowlist_still_passes() {
2621        let policy = Arc::new(test_policy());
2622        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2623        let body = tool_call_body(
2624            "resource_exec",
2625            &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
2626        );
2627        let req = Request::builder()
2628            .method(Method::POST)
2629            .uri("/mcp")
2630            .header("content-type", "application/json")
2631            .body(Body::from(body))
2632            .unwrap();
2633        let resp = app.oneshot(req).await.unwrap();
2634        assert_ne!(resp.status(), StatusCode::FORBIDDEN);
2635    }
2636
2637    // -- F4 regression: non-string `host` downgraded the host-glob check --
2638    //
2639    // `restricted-exec` is scoped to `hosts: ["dev-*"]`. Before the fix,
2640    // `arguments.host` was read with `as_str()`, so any non-string shape
2641    // yielded `None` and routed to `check_operation`, skipping the host
2642    // globs entirely -- letting a caller reach `prod-1` by sending the
2643    // host as an array. Each case below returned 200 before the fix.
2644
2645    async fn exec_status(args: &serde_json::Value) -> StatusCode {
2646        let policy = Arc::new(test_policy());
2647        let app = rbac_router_with_identity(policy, restricted_exec_identity());
2648        let body = tool_call_body("resource_exec", args);
2649        let req = Request::builder()
2650            .method(Method::POST)
2651            .uri("/mcp")
2652            .header("content-type", "application/json")
2653            .body(Body::from(body))
2654            .unwrap();
2655        app.oneshot(req).await.unwrap().status()
2656    }
2657
2658    #[tokio::test]
2659    async fn non_string_host_is_denied_for_every_json_type() {
2660        for host in [
2661            serde_json::json!(["prod-1"]),
2662            serde_json::json!({ "name": "prod-1" }),
2663            serde_json::json!(42),
2664            serde_json::json!(true),
2665            serde_json::json!(null),
2666        ] {
2667            let args = serde_json::json!({ "host": host, "cmd": "sh" });
2668            assert_eq!(
2669                exec_status(&args).await,
2670                StatusCode::FORBIDDEN,
2671                "non-string host must not bypass host globs: {host:?}"
2672            );
2673        }
2674    }
2675
2676    #[tokio::test]
2677    async fn string_host_outside_globs_still_denied() {
2678        let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
2679        assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
2680    }
2681
2682    #[tokio::test]
2683    async fn string_host_inside_globs_still_allowed() {
2684        let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
2685        assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
2686    }
2687
2688    /// Asserts the deliberate scope boundary: an absent `host` still routes
2689    /// to `check_operation` so hostless tools keep working. Requiring a host
2690    /// unconditionally would break `ping` / `list_hosts`.
2691    #[tokio::test]
2692    async fn absent_host_still_routes_to_check_operation() {
2693        let args = serde_json::json!({ "cmd": "sh" });
2694        assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
2695    }
2696
2697    // -- F5: opt-in `required` on ArgumentAllowlist --
2698    //
2699    // An allowlist constrains a value only when the argument is present, so a
2700    // caller could skip it entirely by omitting the key. That is safe when the
2701    // tool's input schema marks the argument required, but fails open when the
2702    // handler substitutes a default. `required` is opt-in so existing configs
2703    // are untouched.
2704
2705    fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
2706        let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2707            .with_argument_allowlists(vec![
2708                ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
2709            ]);
2710        let mut config = RbacConfig::with_roles(vec![role]);
2711        config.enabled = true;
2712        RbacPolicy::new(&config)
2713    }
2714
2715    fn viewer_identity() -> AuthIdentity {
2716        AuthIdentity {
2717            method: crate::auth::AuthMethod::BearerToken,
2718            name: "viewer-1".into(),
2719            role: "viewer".into(),
2720            raw_token: None,
2721            sub: None,
2722        }
2723    }
2724
2725    async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
2726        let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
2727        let body = serde_json::json!({
2728            "jsonrpc": "2.0",
2729            "id": 1,
2730            "method": "tools/call",
2731            "params": params
2732        })
2733        .to_string();
2734        let req = Request::builder()
2735            .method(Method::POST)
2736            .uri("/mcp")
2737            .header("content-type", "application/json")
2738            .body(Body::from(body))
2739            .unwrap();
2740        app.oneshot(req).await.unwrap().status()
2741    }
2742
2743    #[tokio::test]
2744    async fn required_false_still_allows_omitting_the_argument() {
2745        let params = serde_json::json!({ "name": "run", "arguments": {} });
2746        assert_ne!(
2747            run_status(required_policy(vec!["ls".into()], false), &params).await,
2748            StatusCode::FORBIDDEN,
2749            "default behaviour must be unchanged"
2750        );
2751    }
2752
2753    #[tokio::test]
2754    async fn required_true_denies_omitted_argument() {
2755        let params = serde_json::json!({ "name": "run", "arguments": {} });
2756        assert_eq!(
2757            run_status(required_policy(vec!["ls".into()], true), &params).await,
2758            StatusCode::FORBIDDEN
2759        );
2760    }
2761
2762    #[tokio::test]
2763    async fn required_true_allows_permitted_value() {
2764        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
2765        assert_ne!(
2766            run_status(required_policy(vec!["ls".into()], true), &params).await,
2767            StatusCode::FORBIDDEN
2768        );
2769    }
2770
2771    #[tokio::test]
2772    async fn required_true_still_denies_disallowed_value() {
2773        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
2774        assert_eq!(
2775            run_status(required_policy(vec!["ls".into()], true), &params).await,
2776            StatusCode::FORBIDDEN
2777        );
2778    }
2779
2780    #[tokio::test]
2781    async fn required_true_denies_non_string_value() {
2782        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
2783        assert_eq!(
2784            run_status(required_policy(vec!["ls".into()], true), &params).await,
2785            StatusCode::FORBIDDEN
2786        );
2787    }
2788
2789    #[tokio::test]
2790    async fn required_true_denies_absent_or_non_object_arguments() {
2791        for params in [
2792            serde_json::json!({ "name": "run" }),
2793            serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
2794            serde_json::json!({ "name": "run", "arguments": null }),
2795        ] {
2796            assert_eq!(
2797                run_status(required_policy(vec!["ls".into()], true), &params).await,
2798                StatusCode::FORBIDDEN,
2799                "omitting the arguments object must not skip `required`: {params:?}"
2800            );
2801        }
2802    }
2803
2804    // Empty `allowed` means "unrestricted value". Combined with `required`
2805    // that is "must be supplied as a string, any value accepted".
2806    #[tokio::test]
2807    async fn required_true_with_empty_allowed_accepts_any_string() {
2808        let params =
2809            serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
2810        assert_ne!(
2811            run_status(required_policy(vec![], true), &params).await,
2812            StatusCode::FORBIDDEN
2813        );
2814    }
2815
2816    #[tokio::test]
2817    async fn required_true_with_empty_allowed_denies_omitted_argument() {
2818        let params = serde_json::json!({ "name": "run", "arguments": {} });
2819        assert_eq!(
2820            run_status(required_policy(vec![], true), &params).await,
2821            StatusCode::FORBIDDEN
2822        );
2823    }
2824
2825    #[tokio::test]
2826    async fn required_true_with_empty_allowed_denies_non_string() {
2827        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
2828        assert_eq!(
2829            run_status(required_policy(vec![], true), &params).await,
2830            StatusCode::FORBIDDEN
2831        );
2832    }
2833
2834    #[tokio::test]
2835    async fn required_honours_globbed_tool_patterns() {
2836        let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2837            .with_argument_allowlists(vec![
2838                ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
2839            ]);
2840        let mut config = RbacConfig::with_roles(vec![role]);
2841        config.enabled = true;
2842        let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
2843        assert_eq!(
2844            run_status(RbacPolicy::new(&config), &params).await,
2845            StatusCode::FORBIDDEN,
2846            "a globbed tool pattern must enforce presence, not just value"
2847        );
2848    }
2849
2850    #[test]
2851    fn required_defaults_to_false_when_absent_from_toml() {
2852        let cfg: RbacConfig = toml::from_str(
2853            r#"
2854            enabled = true
2855            [[roles]]
2856            name = "viewer"
2857            allow = ["run"]
2858            [[roles.argument_allowlists]]
2859            tool = "run"
2860            argument = "cmd"
2861            allowed = ["ls"]
2862            "#,
2863        )
2864        .expect("config without `required` must still deserialize");
2865        assert!(
2866            !cfg.roles[0].argument_allowlists[0].required,
2867            "omitted `required` must default to false so existing configs are unchanged"
2868        );
2869    }
2870}