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