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