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                    "argument allowlist is optional and fails open when the \
1035                     argument is omitted: the allowed-value list is enforced \
1036                     only if the caller supplies the argument, so a tool that \
1037                     substitutes its own default bypasses it entirely -- set \
1038                     `required = true` in TOML, or construct via \
1039                     `ArgumentAllowlist::new_required`, to reject calls that \
1040                     omit it"
1041                );
1042            }
1043        }
1044    }
1045}
1046
1047/// Process-wide random redaction salt, lazily generated on first use.
1048/// Used when [`RbacConfig::redaction_salt`] is `None`.
1049fn process_redaction_salt() -> &'static SecretString {
1050    use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
1051    static PROCESS_SALT: std::sync::OnceLock<SecretString> = std::sync::OnceLock::new();
1052    PROCESS_SALT.get_or_init(|| {
1053        let mut bytes = [0u8; 32];
1054        rand::fill(&mut bytes);
1055        // base64-encode so the SecretString is valid UTF-8; the HMAC
1056        // accepts arbitrary key bytes regardless.
1057        SecretString::from(STANDARD_NO_PAD.encode(bytes))
1058    })
1059}
1060
1061/// HMAC-SHA256(`salt`, `value`) → first 8 hex chars.
1062///
1063/// Pulled out as a free function so it can be unit-tested and benchmarked
1064/// without constructing a full [`RbacPolicy`].
1065fn redact_with_salt(salt: &[u8], value: &str) -> String {
1066    use std::fmt::Write as _;
1067
1068    use sha2::Digest as _;
1069
1070    type HmacSha256 = Hmac<Sha256>;
1071    // HMAC-SHA256 accepts keys of any byte length: the spec pads short
1072    // keys with zeros and hashes long keys, so `new_from_slice` is
1073    // infallible here. We still defensively re-key with a SHA-256 of
1074    // the salt if construction ever fails (e.g. future hmac upstream
1075    // tightens the contract); both branches produce a valid keyed MAC.
1076    let mut mac = if let Ok(m) = HmacSha256::new_from_slice(salt) {
1077        m
1078    } else {
1079        let digest = Sha256::digest(salt);
1080        #[allow(
1081            clippy::expect_used,
1082            reason = "32-byte SHA-256 digest is unconditionally valid as an HMAC-SHA256 key (RFC 2104 allows any key length); see surrounding comment"
1083        )]
1084        HmacSha256::new_from_slice(&digest).expect("32-byte SHA256 digest is valid HMAC key")
1085    };
1086    mac.update(value.as_bytes());
1087    let bytes = mac.finalize().into_bytes();
1088    // 4 bytes → 8 hex chars.
1089    let prefix = bytes.get(..4).unwrap_or(&[0; 4]);
1090    let mut out = String::with_capacity(8);
1091    for b in prefix {
1092        let _ = write!(out, "{b:02x}");
1093    }
1094    out
1095}
1096
1097// -- RBAC middleware --
1098
1099/// Axum middleware that enforces RBAC and per-IP tool rate limiting on
1100/// MCP tool calls.
1101///
1102/// Inspects POST request bodies for `tools/call` JSON-RPC messages,
1103/// extracts the tool name and `host` argument, and checks the
1104/// [`RbacPolicy`] against the [`AuthIdentity`] set by the auth middleware.
1105///
1106/// When a `tool_limiter` is provided, tool invocations are rate-limited
1107/// per source IP regardless of whether RBAC is enabled (MCP spec: servers
1108/// MUST rate limit tool invocations).
1109///
1110/// Non-POST requests and non-tool-call messages pass through unchanged.
1111/// The caller's role is stored in task-local storage for use by tool
1112/// handlers (e.g. `list_hosts` host filtering via [`current_role()`]).
1113// NOTE: cognitive complexity reduced from 43/25 by extracting
1114// `enforce_tool_policy` and `enforce_rate_limit`. Remaining flow is a
1115// linear body-collect + JSON-RPC parse + dispatch, intentionally left
1116// inline to keep the request lifecycle visible at a glance.
1117#[allow(
1118    clippy::too_many_lines,
1119    reason = "linear request lifecycle (body collect → JSON-RPC parse → policy dispatch) kept inline for security review visibility; helpers already extracted"
1120)]
1121// cancel-safe: `TimeoutLayer` may drop during `body.collect` or `next.run`;
1122// buffered body/task-local scopes are request-local, and tool limiter checks
1123// deliberately price attempted tool calls even if the handler times out.
1124pub(crate) async fn rbac_middleware(
1125    policy: Arc<RbacPolicy>,
1126    tool_limiter: Option<Arc<ToolRateLimiter>>,
1127    req: Request<Body>,
1128    next: Next,
1129) -> Response {
1130    // Only inspect POST requests - tool calls are POSTs.
1131    if req.method() != Method::POST {
1132        return next.run(req).await;
1133    }
1134
1135    // Extract the rate-limit key (resolved client IP when trusted-forwarder
1136    // mode is active, else the direct peer).
1137    // Resolved only when the tool limiter will actually consult it, so
1138    // servers without tool rate limiting never trip the
1139    // unattributed-fallback warning.
1140    let peer_key = tool_limiter
1141        .is_some()
1142        .then(|| crate::transport::limiter_client_key(req.extensions()));
1143
1144    // Extract caller identity and role (may be absent when auth is off).
1145    let identity = req.extensions().get::<AuthIdentity>();
1146    let identity_name = identity.map(|id| id.name.clone()).unwrap_or_default();
1147    let role = identity.map(|id| id.role.clone()).unwrap_or_default();
1148    // Clone the SecretString end-to-end; an absent token becomes an empty
1149    // SecretString sentinel (current_token() filters this out as None).
1150    let raw_token: SecretString = identity
1151        .and_then(|id| id.raw_token.clone())
1152        .unwrap_or_else(|| SecretString::from(String::new()));
1153    let sub = identity.and_then(|id| id.sub.clone()).unwrap_or_default();
1154
1155    // RBAC requires an authenticated identity.
1156    if policy.is_enabled() && identity.is_none() {
1157        return RmcpServerKitError::Rbac("no authenticated identity".into()).into_response();
1158    }
1159
1160    // Read the body for JSON-RPC inspection.
1161    let (parts, body) = req.into_parts();
1162    let bytes = match body.collect().await {
1163        Ok(collected) => collected.to_bytes(),
1164        Err(e) => {
1165            tracing::error!(error = %e, "failed to read request body");
1166            return (
1167                StatusCode::INTERNAL_SERVER_ERROR,
1168                "failed to read request body",
1169            )
1170                .into_response();
1171        }
1172    };
1173
1174    // Try to parse as JSON and inspect JSON-RPC tool calls, including batch arrays.
1175    if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
1176        let tool_calls = extract_tool_calls(&json);
1177        if !tool_calls.is_empty() {
1178            for params in tool_calls {
1179                if let Some(resp) = enforce_rate_limit(tool_limiter.as_deref(), peer_key.as_ref()) {
1180                    #[cfg(feature = "metrics")]
1181                    crate::metrics::record_rate_limit_deny(&parts.extensions, "tool");
1182                    return resp;
1183                }
1184                if policy.is_enabled()
1185                    && let Some(resp) = enforce_tool_policy(&policy, &identity_name, &role, params)
1186                {
1187                    return resp;
1188                }
1189            }
1190        }
1191    }
1192    // Non-parseable or non-tool-call requests pass through.
1193
1194    // Reconstruct the request with the consumed body.
1195    let req = Request::from_parts(parts, Body::from(bytes));
1196
1197    // Set the caller's role and identity in task-local storage for the handler.
1198    if role.is_empty() {
1199        next.run(req).await
1200    } else {
1201        CURRENT_ROLE
1202            .scope(
1203                role,
1204                CURRENT_IDENTITY.scope(
1205                    identity_name,
1206                    CURRENT_TOKEN.scope(raw_token, CURRENT_SUB.scope(sub, next.run(req))),
1207                ),
1208            )
1209            .await
1210    }
1211}
1212
1213/// Extract the `params` object for every top-level `tools/call` message.
1214///
1215/// Supports either a single JSON-RPC object or a JSON-RPC batch array. Any
1216/// malformed elements are ignored so non-RPC payloads continue to pass through
1217/// unchanged.
1218fn extract_tool_calls(value: &serde_json::Value) -> Vec<&serde_json::Value> {
1219    match value {
1220        serde_json::Value::Object(map) => map
1221            .get("method")
1222            .and_then(serde_json::Value::as_str)
1223            .filter(|method| *method == "tools/call")
1224            .and_then(|_| map.get("params"))
1225            .into_iter()
1226            .collect(),
1227        serde_json::Value::Array(items) => items
1228            .iter()
1229            .filter_map(|item| match item {
1230                serde_json::Value::Object(map) => map
1231                    .get("method")
1232                    .and_then(serde_json::Value::as_str)
1233                    .filter(|method| *method == "tools/call")
1234                    .and_then(|_| map.get("params")),
1235                serde_json::Value::Null
1236                | serde_json::Value::Bool(_)
1237                | serde_json::Value::Number(_)
1238                | serde_json::Value::String(_)
1239                | serde_json::Value::Array(_) => None,
1240            })
1241            .collect(),
1242        serde_json::Value::Null
1243        | serde_json::Value::Bool(_)
1244        | serde_json::Value::Number(_)
1245        | serde_json::Value::String(_) => Vec::new(),
1246    }
1247}
1248
1249/// Per-IP rate limit check for tool invocations. Returns `Some(response)`
1250/// if the caller should be rejected.
1251fn enforce_rate_limit(
1252    tool_limiter: Option<&ToolRateLimiter>,
1253    peer_key: Option<&crate::transport::RateLimitKey>,
1254) -> Option<Response> {
1255    let limiter = tool_limiter?;
1256    let key = peer_key?;
1257    match limiter.check_key_detailed(key) {
1258        Ok(()) => None,
1259        Err(BoundedLimiterDeny::RateLimited(wait)) => {
1260            tracing::warn!(rate_limit_key = %key, "tool invocation rate limited");
1261            Some(
1262                RmcpServerKitError::RateLimitedFor {
1263                    message: "too many tool invocations".into(),
1264                    retry_after: wait,
1265                }
1266                .into_response(),
1267            )
1268        }
1269        Err(BoundedLimiterDeny::CapacityFull) => {
1270            tracing::warn!(
1271                rate_limit_key = %key,
1272                "tool invocation limiter rejected unseen key because tracked-key capacity is full"
1273            );
1274            Some(
1275                (
1276                    StatusCode::SERVICE_UNAVAILABLE,
1277                    "rate limiter capacity exhausted",
1278                )
1279                    .into_response(),
1280            )
1281        }
1282    }
1283}
1284
1285/// Apply RBAC tool/host + argument-allowlist checks. Returns `Some(response)`
1286/// when the caller must be rejected. Assumes `policy.is_enabled()`.
1287///
1288/// `identity_name` is passed explicitly (rather than read from
1289/// [`current_identity()`]) because this function runs *before* the
1290/// task-local context is installed by the middleware. Reading the
1291/// task-local here would always yield `None`, producing deny logs with
1292/// an empty `user` field.
1293fn enforce_tool_policy(
1294    policy: &RbacPolicy,
1295    identity_name: &str,
1296    role: &str,
1297    params: &serde_json::Value,
1298) -> Option<Response> {
1299    let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
1300    let host_value = params.get("arguments").and_then(|a| a.get("host"));
1301
1302    // M2 precedent (see `check_argument`): a caller-supplied `host` of the
1303    // wrong JSON type must not silently downgrade the host-glob check to an
1304    // operation-only check. `as_str()` on an array/object/number/bool/null
1305    // yields `None`, which would route to `check_operation` and skip
1306    // `RoleConfig.hosts` entirely -- letting a caller opt out of host
1307    // restrictions by changing the argument's shape. Fail closed, and log
1308    // the type rather than the value so no caller input is leaked.
1309    if let Some(value) = host_value
1310        && !value.is_string()
1311    {
1312        tracing::warn!(
1313            user = %identity_name,
1314            role = %role,
1315            tool = tool_name,
1316            value_type = json_value_type(value),
1317            "non-string host argument rejected"
1318        );
1319        return Some(
1320            RmcpServerKitError::Rbac(format!(
1321                "argument 'host' must be a string for tool '{tool_name}'"
1322            ))
1323            .into_response(),
1324        );
1325    }
1326    // Absent `host` still routes to `check_operation` by design: hostless
1327    // tools (`ping`, `list_hosts`) legitimately carry no host argument.
1328    let host = host_value.and_then(|h| h.as_str());
1329
1330    let decision = if let Some(host) = host {
1331        policy.check(role, tool_name, host)
1332    } else {
1333        policy.check_operation(role, tool_name)
1334    };
1335    if decision == RbacDecision::Deny {
1336        tracing::warn!(
1337            user = %identity_name,
1338            role = %role,
1339            tool = tool_name,
1340            host = host.unwrap_or("-"),
1341            "RBAC denied"
1342        );
1343        return Some(
1344            RmcpServerKitError::Rbac(format!("{tool_name} denied for role '{role}'"))
1345                .into_response(),
1346        );
1347    }
1348
1349    let args = params.get("arguments").and_then(|a| a.as_object());
1350    let strict = policy.strict_argument_names(role, tool_name);
1351    if let Some(args) = args {
1352        for (arg_key, arg_val) in args {
1353            if let Some(ref permitted) = strict
1354                && let Some(resp) = check_strict_argument(
1355                    identity_name,
1356                    role,
1357                    tool_name,
1358                    permitted,
1359                    arg_key,
1360                    arg_val,
1361                )
1362            {
1363                return Some(resp);
1364            }
1365            if let Some(resp) =
1366                check_argument(policy, identity_name, role, tool_name, arg_key, arg_val)
1367            {
1368                return Some(resp);
1369            }
1370        }
1371    }
1372    check_required_arguments(policy, identity_name, role, tool_name, args)
1373}
1374
1375/// Deny arguments outside the allowlisted set when strict confinement is on.
1376///
1377/// Object and array values are denied outright: their contents cannot be
1378/// constrained, so admitting them would reopen the bypass one level down.
1379fn check_strict_argument(
1380    identity_name: &str,
1381    role: &str,
1382    tool_name: &str,
1383    permitted: &[&str],
1384    arg_key: &str,
1385    arg_val: &serde_json::Value,
1386) -> Option<Response> {
1387    if !permitted.contains(&arg_key) {
1388        tracing::warn!(
1389            user = %identity_name,
1390            role = %role,
1391            tool = tool_name,
1392            argument = arg_key,
1393            "unknown argument rejected by strict allowlist"
1394        );
1395        return Some(
1396            RmcpServerKitError::Rbac(format!(
1397                "argument '{arg_key}' is not permitted for tool '{tool_name}'"
1398            ))
1399            .into_response(),
1400        );
1401    }
1402    if arg_val.is_object() || arg_val.is_array() {
1403        tracing::warn!(
1404            user = %identity_name,
1405            role = %role,
1406            tool = tool_name,
1407            argument = arg_key,
1408            value_type = json_value_type(arg_val),
1409            "structured argument rejected by strict allowlist"
1410        );
1411        return Some(
1412            RmcpServerKitError::Rbac(format!(
1413                "argument '{arg_key}' must not be an object or array for tool '{tool_name}'"
1414            ))
1415            .into_response(),
1416        );
1417    }
1418    None
1419}
1420
1421/// Deny when a `required` argument is missing or not string-valued.
1422///
1423/// Absence can only be judged here: [`check_argument`] is keyed by a present
1424/// argument and structurally cannot observe a missing one. This runs even when
1425/// `args` is `None` -- i.e. the call carried no `arguments` object, or a
1426/// non-object -- because returning early on that would let a caller skip every
1427/// `required` constraint by omitting the object entirely.
1428fn check_required_arguments(
1429    policy: &RbacPolicy,
1430    identity_name: &str,
1431    role: &str,
1432    tool_name: &str,
1433    args: Option<&serde_json::Map<String, serde_json::Value>>,
1434) -> Option<Response> {
1435    let missing = policy.missing_required_argument(role, tool_name, args)?;
1436    tracing::warn!(
1437        user = %identity_name,
1438        role = %role,
1439        tool = tool_name,
1440        argument = missing,
1441        "required argument missing"
1442    );
1443    Some(
1444        RmcpServerKitError::Rbac(format!(
1445            "argument '{missing}' is required for tool '{tool_name}'"
1446        ))
1447        .into_response(),
1448    )
1449}
1450
1451fn check_argument(
1452    policy: &RbacPolicy,
1453    identity_name: &str,
1454    role: &str,
1455    tool_name: &str,
1456    arg_key: &str,
1457    arg_val: &serde_json::Value,
1458) -> Option<Response> {
1459    if !policy.has_argument_allowlist(role, tool_name, arg_key) {
1460        return None;
1461    }
1462    let Some(val_str) = arg_val.as_str() else {
1463        // M2: an allowlist is configured for this argument but the
1464        // caller sent a non-string JSON value (array/object/number/
1465        // bool/null), which can never satisfy a `Vec<String>`
1466        // allowlist. Fail closed; log the type (not the value) so
1467        // operators see the rejected shape without leaking inputs.
1468        tracing::warn!(
1469            user = %identity_name,
1470            role = %role,
1471            tool = tool_name,
1472            argument = arg_key,
1473            value_type = json_value_type(arg_val),
1474            "non-string argument rejected by allowlist"
1475        );
1476        return Some(
1477            RmcpServerKitError::Rbac(format!(
1478                "argument '{arg_key}' must be a string for tool '{tool_name}'"
1479            ))
1480            .into_response(),
1481        );
1482    };
1483    if policy.argument_allowed(role, tool_name, arg_key, val_str) {
1484        return None;
1485    }
1486    // Redact the raw value: log an HMAC-SHA256 prefix instead of
1487    // the literal string. Operators correlate hashes across log
1488    // lines without ever exposing potentially sensitive inputs
1489    // (paths, IDs, tokens accidentally passed as args, etc.).
1490    tracing::warn!(
1491        user = %identity_name,
1492        role = %role,
1493        tool = tool_name,
1494        argument = arg_key,
1495        arg_hmac = %policy.redact_arg(val_str),
1496        "argument not in allowlist"
1497    );
1498    Some(
1499        RmcpServerKitError::Rbac(format!(
1500            "argument '{arg_key}' value not in allowlist for tool '{tool_name}'"
1501        ))
1502        .into_response(),
1503    )
1504}
1505
1506fn json_value_type(v: &serde_json::Value) -> &'static str {
1507    match v {
1508        serde_json::Value::Null => "null",
1509        serde_json::Value::Bool(_) => "bool",
1510        serde_json::Value::Number(_) => "number",
1511        serde_json::Value::String(_) => "string",
1512        serde_json::Value::Array(_) => "array",
1513        serde_json::Value::Object(_) => "object",
1514    }
1515}
1516
1517/// Simple glob matching: `*` matches any sequence of characters.
1518///
1519/// Supports multiple `*` wildcards anywhere in the pattern.
1520/// No `?`, `[...]`, or other advanced glob features.
1521///
1522/// All slice offsets are derived from `starts_with`/`ends_with`/`find`,
1523/// which guarantee char-boundary alignment; the `get(..)` accessors keep
1524/// that machine-checked (a violated invariant degrades to a non-match
1525/// instead of a panic).
1526fn glob_match(pattern: &str, text: &str) -> bool {
1527    let parts: Vec<&str> = pattern.split('*').collect();
1528    if parts.len() == 1 {
1529        // No wildcards - exact match.
1530        return pattern == text;
1531    }
1532
1533    // First part must match at the start (unless pattern starts with *).
1534    let pos = if let Some(&first) = parts.first()
1535        && !first.is_empty()
1536    {
1537        if !text.starts_with(first) {
1538            return false;
1539        }
1540        first.len()
1541    } else {
1542        0
1543    };
1544
1545    // Last part must match at the end (unless pattern ends with *).
1546    if let Some(&last) = parts.last()
1547        && !last.is_empty()
1548    {
1549        if !text.get(pos..).unwrap_or_default().ends_with(last) {
1550            return false;
1551        }
1552        // Shrink the search area so middle parts don't overlap with the suffix.
1553        let end = text.len() - last.len();
1554        if pos > end {
1555            return false;
1556        }
1557        // Check middle parts in the remaining region.
1558        let middle = text.get(pos..end).unwrap_or_default();
1559        let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1560        return match_middle(middle, middle_parts);
1561    }
1562
1563    // Pattern ends with * - just check middle parts.
1564    let middle = text.get(pos..).unwrap_or_default();
1565    let middle_parts = parts.get(1..parts.len() - 1).unwrap_or_default();
1566    match_middle(middle, middle_parts)
1567}
1568
1569/// Match middle glob segments sequentially in `text`.
1570fn match_middle(mut text: &str, parts: &[&str]) -> bool {
1571    for part in parts {
1572        if part.is_empty() {
1573            continue;
1574        }
1575        if let Some(idx) = text.find(part) {
1576            text = text.get(idx + part.len()..).unwrap_or_default();
1577        } else {
1578            return false;
1579        }
1580    }
1581    true
1582}
1583
1584impl RbacConfig {
1585    /// Applies `RMCP_SERVER_KIT__RBAC__*` environment overrides.
1586    ///
1587    /// Supports direct `redaction_salt` and `_FILE` secret indirection. Report
1588    /// entries for the secret target always redact the value. File-based
1589    /// secrets are treated as text: exactly one terminal line ending is removed
1590    /// (`\r\n`, `\n`, or `\r`) while other whitespace is preserved.
1591    ///
1592    /// # Errors
1593    ///
1594    /// Returns [`RmcpServerKitError::Config`] when both direct and file-based salt
1595    /// variables are set or when the `_FILE` target cannot be read.
1596    ///
1597    /// # Examples
1598    ///
1599    /// The full config-file pipeline lives in
1600    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
1601    ///
1602    /// ```no_run
1603    /// use rmcp_server_kit::rbac::RbacConfig;
1604    ///
1605    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1606    /// let mut rbac = RbacConfig::default();
1607    /// // Do not set process env in doctests: rustdoc examples share a process.
1608    /// let report = rbac.apply_env_overrides()?;
1609    /// let _secret_targets: Vec<&str> = report
1610    ///     .iter()
1611    ///     .filter(|entry| entry.value.is_none())
1612    ///     .map(|entry| entry.target_field.as_str())
1613    ///     .collect();
1614    /// # Ok(())
1615    /// # }
1616    /// ```
1617    pub fn apply_env_overrides(
1618        &mut self,
1619    ) -> Result<Vec<crate::config::EnvOverride>, RmcpServerKitError> {
1620        let direct = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_ENV)?;
1621        let file = crate::config::read_env(crate::config::RBAC_REDACTION_SALT_FILE_ENV)?;
1622        match (direct, file) {
1623            (None, None) => Ok(Vec::new()),
1624            (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
1625                "{} and {} must not both be set",
1626                crate::config::RBAC_REDACTION_SALT_ENV,
1627                crate::config::RBAC_REDACTION_SALT_FILE_ENV
1628            ))),
1629            (Some(value), None) => {
1630                reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_ENV, &value)?;
1631                self.redaction_salt = Some(SecretString::from(value));
1632                Ok(vec![crate::config::secret_env_report(
1633                    crate::config::RBAC_REDACTION_SALT_ENV,
1634                    "rbac.redaction_salt",
1635                    crate::config::EnvOverrideSource::Env,
1636                )])
1637            }
1638            (None, Some(path)) => {
1639                let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
1640                    RmcpServerKitError::Config(format!(
1641                        "failed to read {} file {path:?}: {error}",
1642                        crate::config::RBAC_REDACTION_SALT_FILE_ENV
1643                    ))
1644                })?;
1645                let secret = crate::config::normalize_text_secret_file(secret);
1646                reject_blank_redaction_salt(crate::config::RBAC_REDACTION_SALT_FILE_ENV, &secret)?;
1647                self.redaction_salt = Some(SecretString::from(secret));
1648                Ok(vec![crate::config::secret_env_report(
1649                    crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1650                    "rbac.redaction_salt",
1651                    crate::config::EnvOverrideSource::File,
1652                )])
1653            }
1654        }
1655    }
1656}
1657
1658fn reject_blank_redaction_salt(env_var: &str, value: &str) -> Result<(), RmcpServerKitError> {
1659    if value.trim().is_empty() {
1660        return Err(RmcpServerKitError::Config(format!(
1661            "{env_var} must not be empty or whitespace-only"
1662        )));
1663    }
1664    Ok(())
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669    use std::net::IpAddr;
1670
1671    use super::*;
1672    use crate::transport::RateLimitKey;
1673
1674    fn with_rbac_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1675        temp_env::with_vars(
1676            [
1677                (crate::config::RBAC_REDACTION_SALT_ENV, None::<&str>),
1678                (crate::config::RBAC_REDACTION_SALT_FILE_ENV, None::<&str>),
1679            ]
1680            .into_iter()
1681            .chain(vars.iter().copied())
1682            .collect::<Vec<_>>(),
1683            f,
1684        )
1685    }
1686
1687    #[test]
1688    fn e6_redaction_salt_env_applies_and_report_redacts_value() {
1689        with_rbac_env(
1690            &[(crate::config::RBAC_REDACTION_SALT_ENV, Some("s3cret"))],
1691            || {
1692                let mut cfg = RbacConfig::default();
1693                let report = cfg.apply_env_overrides().unwrap();
1694                assert!(cfg.redaction_salt.is_some());
1695                assert_eq!(report.len(), 1);
1696                assert_eq!(report[0].env_var, crate::config::RBAC_REDACTION_SALT_ENV);
1697                assert_eq!(report[0].target_field, "rbac.redaction_salt");
1698                assert_eq!(report[0].source, crate::config::EnvOverrideSource::Env);
1699                assert!(report[0].value.is_none());
1700                assert!(!format!("{report:?}").contains("s3cret"));
1701            },
1702        );
1703    }
1704
1705    #[test]
1706    fn e7_redaction_salt_value_and_file_conflict_fails() {
1707        with_rbac_env(
1708            &[
1709                (crate::config::RBAC_REDACTION_SALT_ENV, Some("direct")),
1710                (
1711                    crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1712                    Some("/tmp/secret-file"),
1713                ),
1714            ],
1715            || {
1716                let mut cfg = RbacConfig::default();
1717                let err = cfg.apply_env_overrides().unwrap_err();
1718                let msg = err.to_string();
1719                assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_ENV));
1720                assert!(msg.contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV));
1721            },
1722        );
1723    }
1724
1725    #[test]
1726    fn e8_redaction_salt_file_env_reads_secret_and_reports_file_source() {
1727        let (file_redaction, report) = redaction_from_file("same-salt\n").expect("file salt");
1728        let direct_redaction = redaction_from_direct_salt("same-salt");
1729
1730        assert_eq!(file_redaction, direct_redaction);
1731        assert_eq!(report.len(), 1);
1732        assert_eq!(
1733            report[0].env_var,
1734            crate::config::RBAC_REDACTION_SALT_FILE_ENV
1735        );
1736        assert_eq!(report[0].target_field, "rbac.redaction_salt");
1737        assert_eq!(report[0].source, crate::config::EnvOverrideSource::File);
1738        assert!(report[0].value.is_none());
1739    }
1740
1741    #[test]
1742    fn redaction_salt_file_normalizes_crlf_and_preserves_spaces() {
1743        let (crlf_redaction, _) = redaction_from_file("same-salt\r\n").expect("crlf salt");
1744        assert_eq!(crlf_redaction, redaction_from_direct_salt("same-salt"));
1745
1746        let (spaced_redaction, _) = redaction_from_file("  same-salt  \n").expect("spaced salt");
1747        assert_eq!(
1748            spaced_redaction,
1749            redaction_from_direct_salt("  same-salt  ")
1750        );
1751        assert_ne!(spaced_redaction, redaction_from_direct_salt("same-salt"));
1752    }
1753
1754    #[derive(Clone, Default)]
1755    struct CapturedLogs(Arc<std::sync::Mutex<Vec<u8>>>);
1756
1757    impl CapturedLogs {
1758        fn contents(&self) -> String {
1759            let bytes = self.0.lock().map(|guard| guard.clone()).unwrap_or_default();
1760            String::from_utf8(bytes).unwrap_or_default()
1761        }
1762    }
1763
1764    struct CapturedLogsWriter(Arc<std::sync::Mutex<Vec<u8>>>);
1765
1766    impl std::io::Write for CapturedLogsWriter {
1767        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1768            if let Ok(mut guard) = self.0.lock() {
1769                guard.extend_from_slice(buf);
1770            }
1771            Ok(buf.len())
1772        }
1773
1774        fn flush(&mut self) -> std::io::Result<()> {
1775            Ok(())
1776        }
1777    }
1778
1779    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
1780        type Writer = CapturedLogsWriter;
1781
1782        fn make_writer(&'a self) -> Self::Writer {
1783            CapturedLogsWriter(Arc::clone(&self.0))
1784        }
1785    }
1786
1787    fn allowlist_warning_policy(allowlist: ArgumentAllowlist) -> RbacConfig {
1788        RbacConfig::with_roles(vec![
1789            RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
1790                .with_argument_allowlists(vec![allowlist]),
1791        ])
1792    }
1793
1794    fn capture_policy_construction_logs(config: &RbacConfig) -> String {
1795        let logs = CapturedLogs::default();
1796        let subscriber = tracing_subscriber::fmt()
1797            .with_writer(logs.clone())
1798            .with_ansi(false)
1799            .without_time()
1800            .finish();
1801        let _guard = tracing::subscriber::set_default(subscriber);
1802
1803        let _policy = RbacPolicy::new(config);
1804        logs.contents()
1805    }
1806
1807    #[test]
1808    fn optional_non_empty_argument_allowlist_warns_once_at_policy_construction() {
1809        let config =
1810            allowlist_warning_policy(ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]));
1811
1812        let logs = capture_policy_construction_logs(&config);
1813
1814        assert_eq!(
1815            logs.matches("argument allowlist is optional and fails open")
1816                .count(),
1817            1,
1818            "exactly one warning expected for one optional non-empty allowlist: {logs}"
1819        );
1820        assert!(logs.contains("run"), "warning must name the tool: {logs}");
1821        assert!(
1822            logs.contains("cmd"),
1823            "warning must name the argument: {logs}"
1824        );
1825        assert!(
1826            logs.contains("required = true") && logs.contains("new_required"),
1827            "warning must name the remedy so an operator can act on it: {logs}"
1828        );
1829    }
1830
1831    #[test]
1832    fn required_argument_allowlist_does_not_warn_at_policy_construction() {
1833        let config = allowlist_warning_policy(
1834            ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]).with_required(true),
1835        );
1836
1837        let logs = capture_policy_construction_logs(&config);
1838
1839        assert!(
1840            !logs.contains("argument allowlist is optional and fails open"),
1841            "required allowlist must not warn: {logs}"
1842        );
1843    }
1844
1845    #[test]
1846    fn new_required_sets_required_and_preserves_value_allowlist_behavior() {
1847        let optional = ArgumentAllowlist::new("run", "cmd", vec!["ls".into()]);
1848        let required = ArgumentAllowlist::new_required("run", "cmd", vec!["ls".into()]);
1849
1850        assert_eq!(required.tool, optional.tool);
1851        assert_eq!(required.argument, optional.argument);
1852        assert_eq!(required.allowed, optional.allowed);
1853        assert!(required.required);
1854        assert!(!optional.required);
1855
1856        let optional_policy = RbacPolicy::new(&allowlist_warning_policy(optional));
1857        let required_policy = RbacPolicy::new(&allowlist_warning_policy(required));
1858        assert_eq!(
1859            optional_policy.argument_allowed("viewer", "run", "cmd", "ls -la"),
1860            required_policy.argument_allowed("viewer", "run", "cmd", "ls -la")
1861        );
1862        assert_eq!(
1863            optional_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /"),
1864            required_policy.argument_allowed("viewer", "run", "cmd", "rm -rf /")
1865        );
1866    }
1867
1868    #[test]
1869    fn blank_redaction_salt_env_values_fail_closed() {
1870        for value in ["", "\n", "   "] {
1871            with_rbac_env(
1872                &[(crate::config::RBAC_REDACTION_SALT_ENV, Some(value))],
1873                || {
1874                    let mut cfg = RbacConfig::default();
1875                    let err = cfg.apply_env_overrides().unwrap_err();
1876                    assert!(
1877                        err.to_string()
1878                            .contains(crate::config::RBAC_REDACTION_SALT_ENV)
1879                    );
1880                },
1881            );
1882        }
1883    }
1884
1885    #[test]
1886    fn blank_redaction_salt_file_values_fail_closed() {
1887        for value in ["", "\n", "\r\n", "   \n"] {
1888            let err = redaction_from_file(value).unwrap_err();
1889            assert!(
1890                err.to_string()
1891                    .contains(crate::config::RBAC_REDACTION_SALT_FILE_ENV)
1892            );
1893        }
1894    }
1895
1896    fn redaction_from_direct_salt(salt: &str) -> String {
1897        RbacPolicy::new(&RbacConfig {
1898            redaction_salt: Some(SecretString::from(salt.to_owned())),
1899            ..RbacConfig::default()
1900        })
1901        .redact_arg("same-argument")
1902    }
1903
1904    fn redaction_from_file(
1905        content: &str,
1906    ) -> Result<(String, Vec<crate::config::EnvOverride>), RmcpServerKitError> {
1907        let path = std::env::temp_dir().join(format!(
1908            "rmcp-server-kit-redaction-salt-{}.txt",
1909            std::time::SystemTime::now()
1910                .duration_since(std::time::UNIX_EPOCH)
1911                .expect("clock after epoch")
1912                .as_nanos()
1913        ));
1914        std::fs::write(&path, content).expect("write salt file");
1915        let path_string = path.to_string_lossy().to_string();
1916        let result = with_rbac_env(
1917            &[(
1918                crate::config::RBAC_REDACTION_SALT_FILE_ENV,
1919                Some(path_string.as_str()),
1920            )],
1921            || {
1922                let mut cfg = RbacConfig::default();
1923                let report = cfg.apply_env_overrides()?;
1924                let redaction = RbacPolicy::new(&cfg).redact_arg("same-argument");
1925                Ok((redaction, report))
1926            },
1927        );
1928        std::fs::remove_file(path).expect("remove salt file");
1929        result
1930    }
1931
1932    // -- tool rate limiter: burst + Retry-After --
1933
1934    /// Burst capacity admits an initial spike larger than the sustained
1935    /// rate; the next request within the window is denied.
1936    #[test]
1937    fn tool_limiter_burst_allows_initial_spike() {
1938        let limiter = build_tool_rate_limiter_with_policy(2, Some(4), KeyEvictionPolicy::default());
1939        let ip = RateLimitKey::Ip("10.9.9.9".parse::<IpAddr>().unwrap());
1940        for i in 0..4 {
1941            assert!(
1942                limiter.check_key(&ip).is_ok(),
1943                "burst request {i} should pass"
1944            );
1945        }
1946        assert!(
1947            limiter.check_key(&ip).is_err(),
1948            "request 5 must exceed the burst bucket"
1949        );
1950    }
1951
1952    /// The tool-limiter deny response carries a Retry-After header.
1953    #[test]
1954    fn tool_limiter_deny_sets_retry_after() {
1955        let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
1956        let ip = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1957        assert!(enforce_rate_limit(Some(&limiter), Some(&ip)).is_none());
1958        let resp = enforce_rate_limit(Some(&limiter), Some(&ip))
1959            .expect("second call within the window must deny");
1960        assert_eq!(resp.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
1961        let retry_after = resp
1962            .headers()
1963            .get(axum::http::header::RETRY_AFTER)
1964            .expect("Retry-After present")
1965            .to_str()
1966            .unwrap()
1967            .parse::<u64>()
1968            .unwrap();
1969        assert!(retry_after >= 1, "delta-seconds must be >= 1");
1970    }
1971
1972    #[test]
1973    fn tool_limiter_capacity_full_returns_503_without_retry_after() {
1974        let limiter = build_tool_rate_limiter_with_bounds(
1975            10,
1976            None,
1977            1,
1978            Duration::from_hours(1),
1979            KeyEvictionPolicy::RejectNew,
1980        );
1981        let established = RateLimitKey::Ip("10.8.8.8".parse::<IpAddr>().unwrap());
1982        let unseen = RateLimitKey::Ip("10.8.8.9".parse::<IpAddr>().unwrap());
1983        assert!(enforce_rate_limit(Some(&limiter), Some(&established)).is_none());
1984
1985        let resp = enforce_rate_limit(Some(&limiter), Some(&unseen))
1986            .expect("unseen key must be rejected at capacity");
1987
1988        assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
1989        assert!(
1990            resp.headers()
1991                .get(axum::http::header::RETRY_AFTER)
1992                .is_none()
1993        );
1994    }
1995
1996    fn test_policy() -> RbacPolicy {
1997        RbacPolicy::new(&RbacConfig {
1998            enabled: true,
1999            roles: vec![
2000                RoleConfig {
2001                    name: "viewer".into(),
2002                    description: Some("Read-only".into()),
2003                    allow: vec![
2004                        "list_hosts".into(),
2005                        "resource_list".into(),
2006                        "resource_inspect".into(),
2007                        "resource_logs".into(),
2008                        "system_info".into(),
2009                    ],
2010                    deny: vec![],
2011                    hosts: vec!["*".into()],
2012                    argument_allowlists: vec![],
2013                },
2014                RoleConfig {
2015                    name: "deploy".into(),
2016                    description: Some("Lifecycle management".into()),
2017                    allow: vec![
2018                        "list_hosts".into(),
2019                        "resource_list".into(),
2020                        "resource_run".into(),
2021                        "resource_start".into(),
2022                        "resource_stop".into(),
2023                        "resource_restart".into(),
2024                        "resource_logs".into(),
2025                        "image_pull".into(),
2026                    ],
2027                    deny: vec!["resource_delete".into(), "resource_exec".into()],
2028                    hosts: vec!["web-*".into(), "api-*".into()],
2029                    argument_allowlists: vec![],
2030                },
2031                RoleConfig {
2032                    name: "ops".into(),
2033                    description: Some("Full access".into()),
2034                    allow: vec!["*".into()],
2035                    deny: vec![],
2036                    hosts: vec!["*".into()],
2037                    argument_allowlists: vec![],
2038                },
2039                RoleConfig {
2040                    name: "restricted-exec".into(),
2041                    description: Some("Exec with argument allowlist".into()),
2042                    allow: vec!["resource_exec".into()],
2043                    deny: vec![],
2044                    hosts: vec!["dev-*".into()],
2045                    argument_allowlists: vec![ArgumentAllowlist {
2046                        tool: "resource_exec".into(),
2047                        argument: "cmd".into(),
2048                        allowed: vec![
2049                            "sh".into(),
2050                            "bash".into(),
2051                            "cat".into(),
2052                            "ls".into(),
2053                            "ps".into(),
2054                        ],
2055                        required: false,
2056                        deny_unknown_arguments: false,
2057                    }],
2058                },
2059            ],
2060            redaction_salt: None,
2061            ..RbacConfig::default()
2062        })
2063    }
2064
2065    // -- glob_match tests --
2066
2067    #[test]
2068    fn glob_exact_match() {
2069        assert!(glob_match("web-prod-1", "web-prod-1"));
2070        assert!(!glob_match("web-prod-1", "web-prod-2"));
2071    }
2072
2073    #[test]
2074    fn glob_star_suffix() {
2075        assert!(glob_match("web-*", "web-prod-1"));
2076        assert!(glob_match("web-*", "web-staging"));
2077        assert!(!glob_match("web-*", "api-prod"));
2078    }
2079
2080    #[test]
2081    fn glob_star_prefix() {
2082        assert!(glob_match("*-prod", "web-prod"));
2083        assert!(glob_match("*-prod", "api-prod"));
2084        assert!(!glob_match("*-prod", "web-staging"));
2085    }
2086
2087    #[test]
2088    fn glob_star_middle() {
2089        assert!(glob_match("web-*-prod", "web-us-prod"));
2090        assert!(glob_match("web-*-prod", "web-eu-east-prod"));
2091        assert!(!glob_match("web-*-prod", "web-staging"));
2092    }
2093
2094    #[test]
2095    fn glob_star_only() {
2096        assert!(glob_match("*", "anything"));
2097        assert!(glob_match("*", ""));
2098    }
2099
2100    #[test]
2101    fn glob_multiple_stars() {
2102        assert!(glob_match("*web*prod*", "my-web-us-prod-1"));
2103        assert!(!glob_match("*web*prod*", "my-api-us-staging"));
2104    }
2105
2106    /// Pin char-boundary behavior of the `get(..)`-based slicing across
2107    /// multi-byte UTF-8 text: offsets derived from `starts_with` /
2108    /// `ends_with` / `find` are always boundary-aligned, and matching
2109    /// must behave identically to the ASCII cases.
2110    #[test]
2111    fn glob_match_multibyte_utf8() {
2112        assert!(glob_match("hé*llo", "héllo"));
2113        assert!(glob_match("*ö*", "wörld"));
2114        assert!(glob_match("über*", "übermensch"));
2115        assert!(glob_match("*界", "世界"));
2116        assert!(!glob_match("hé*llo", "hello"));
2117        assert!(!glob_match("界*", "世界"));
2118        assert!(glob_match("世*界", "世界"));
2119    }
2120
2121    // -- glob_match boundary / mutation-coverage tests --
2122    //
2123    // The cases below exist to kill specific mutants surfaced by
2124    // `cargo mutants` against `glob_match` / `match_middle` (see
2125    // CI run #84, May 2026). Each test is annotated with the mutation
2126    // it kills so the intent survives future refactors.
2127
2128    /// Kill: `if pos > end` mutated to `pos == end` and `pos >= end`
2129    /// at `glob_match` line 863. The prefix and suffix exactly meet
2130    /// (no characters between them); the original code accepts this,
2131    /// both mutants reject it.
2132    #[test]
2133    fn glob_prefix_and_suffix_meet_exactly() {
2134        // parts = ["ab", "cd"]; first.len()=2, end=text.len()-last.len()=2.
2135        // pos == end → original passes the `pos > end` check, mutants fail.
2136        assert!(glob_match("ab*cd", "abcd"));
2137    }
2138
2139    /// Kill: `parts.len() - 1` mutated to `parts.len() + 1` at line 868
2140    /// (middle-parts slice when pattern has a non-empty suffix). The
2141    /// mutant collapses the middle-parts slice to empty, which would
2142    /// incorrectly accept patterns whose middle segment isn't present.
2143    #[test]
2144    fn glob_middle_segment_required_with_suffix() {
2145        // Pattern requires "b" between "a" and "c"; text omits it.
2146        // Original: middle_parts=["b"], match_middle("xy", ["b"])=false → reject.
2147        // Mutant `+`: middle_parts=[] (slice out of bounds → unwrap_or_default),
2148        //             match_middle("xy", [])=true → wrongly accept.
2149        assert!(!glob_match("a*b*c", "axyc"));
2150    }
2151
2152    /// Kill: `idx + part.len()` mutated to `idx - part.len()` at
2153    /// `match_middle` line 885. The mutant either underflows
2154    /// (panic in test) or fails to advance past the matched part,
2155    /// causing it to re-find the same prefix and accept patterns
2156    /// that should be rejected.
2157    #[test]
2158    fn glob_match_middle_advances_past_matched_part() {
2159        // Original: after finding "ab" at idx 2, advance to text[4..]="_yz",
2160        //           which contains no second "ab" → reject.
2161        // Mutant `-`: text[2-2..]="xxab_yz" → re-finds "ab" → wrongly accept
2162        //             (or panics for the smaller-idx variants).
2163        assert!(!glob_match("*ab*ab*", "xxab_yz"));
2164    }
2165
2166    /// Kill: `idx + part.len()` mutated to `idx * part.len()` at
2167    /// `match_middle` line 885. The mutant computes a different
2168    /// (usually larger) advance offset that produces an out-of-bounds
2169    /// slice and panics, or skips over content that should match.
2170    #[test]
2171    fn glob_match_middle_uses_addition_not_multiplication() {
2172        // Original: find "abcde" at idx 8 in "yyyyyyyyabcde_X", advance
2173        //           to text[13..]="_X", find "X" → accept.
2174        // Mutant `*`: text[8*5..]=text[40..] → out-of-bounds → panic.
2175        assert!(glob_match("*abcde*X*", "yyyyyyyyabcde_X"));
2176    }
2177
2178    // -- RbacPolicy::argument_allowed mutation-coverage tests --
2179
2180    /// Kill: `&&` mutated to `||` at `argument_allowed` line 494.
2181    /// The original short-circuits the allowlist lookup only when both
2182    /// the literal name AND the glob fail to match. The mutant
2183    /// short-circuits when EITHER fails, which means a glob-matched
2184    /// allowlist (literal mismatch, glob match) is silently skipped
2185    /// and the call is wrongly allowed.
2186    #[test]
2187    fn argument_allowed_glob_pattern_with_literal_mismatch_still_enforced() {
2188        // Allowlist registered against pattern "run-*" with allowed=["ls"].
2189        // Calling tool="run-foo" - literal "run-*" != "run-foo" (true),
2190        // but glob_match("run-*", "run-foo") = true.
2191        //   Original `&&`: skip-condition = true && false = false → enforce
2192        //                  allowlist → "rm" not in ["ls"] → deny.
2193        //   Mutant `||`:   skip-condition = true || false = true → skip
2194        //                  allowlist → wrongly allow.
2195        let role = RoleConfig::new("viewer", vec!["run-foo".into()], vec!["*".into()])
2196            .with_argument_allowlists(vec![ArgumentAllowlist::new(
2197                "run-*",
2198                "cmd",
2199                vec!["ls".into()],
2200            )]);
2201        let mut config = RbacConfig::with_roles(vec![role]);
2202        config.enabled = true;
2203        let policy = RbacPolicy::new(&config);
2204        assert!(!policy.argument_allowed("viewer", "run-foo", "cmd", "rm"));
2205    }
2206
2207    // -- RbacPolicy::check tests --
2208
2209    #[test]
2210    fn disabled_policy_allows_everything() {
2211        let policy = RbacPolicy::new(&RbacConfig {
2212            enabled: false,
2213            roles: vec![],
2214            redaction_salt: None,
2215            ..RbacConfig::default()
2216        });
2217        assert_eq!(
2218            policy.check("nonexistent", "resource_delete", "any-host"),
2219            RbacDecision::Allow
2220        );
2221    }
2222
2223    #[test]
2224    fn unknown_role_denied() {
2225        let policy = test_policy();
2226        assert_eq!(
2227            policy.check("unknown", "resource_list", "web-prod-1"),
2228            RbacDecision::Deny
2229        );
2230    }
2231
2232    #[test]
2233    fn viewer_allowed_read_ops() {
2234        let policy = test_policy();
2235        assert_eq!(
2236            policy.check("viewer", "resource_list", "web-prod-1"),
2237            RbacDecision::Allow
2238        );
2239        assert_eq!(
2240            policy.check("viewer", "system_info", "db-host"),
2241            RbacDecision::Allow
2242        );
2243    }
2244
2245    #[test]
2246    fn viewer_denied_write_ops() {
2247        let policy = test_policy();
2248        assert_eq!(
2249            policy.check("viewer", "resource_run", "web-prod-1"),
2250            RbacDecision::Deny
2251        );
2252        assert_eq!(
2253            policy.check("viewer", "resource_delete", "web-prod-1"),
2254            RbacDecision::Deny
2255        );
2256    }
2257
2258    #[test]
2259    fn deploy_allowed_on_matching_hosts() {
2260        let policy = test_policy();
2261        assert_eq!(
2262            policy.check("deploy", "resource_run", "web-prod-1"),
2263            RbacDecision::Allow
2264        );
2265        assert_eq!(
2266            policy.check("deploy", "resource_start", "api-staging"),
2267            RbacDecision::Allow
2268        );
2269    }
2270
2271    #[test]
2272    fn deploy_denied_on_non_matching_host() {
2273        let policy = test_policy();
2274        assert_eq!(
2275            policy.check("deploy", "resource_run", "db-prod-1"),
2276            RbacDecision::Deny
2277        );
2278    }
2279
2280    #[test]
2281    fn deny_overrides_allow() {
2282        let policy = test_policy();
2283        assert_eq!(
2284            policy.check("deploy", "resource_delete", "web-prod-1"),
2285            RbacDecision::Deny
2286        );
2287        assert_eq!(
2288            policy.check("deploy", "resource_exec", "web-prod-1"),
2289            RbacDecision::Deny
2290        );
2291    }
2292
2293    #[test]
2294    fn ops_wildcard_allows_everything() {
2295        let policy = test_policy();
2296        assert_eq!(
2297            policy.check("ops", "resource_delete", "any-host"),
2298            RbacDecision::Allow
2299        );
2300        assert_eq!(
2301            policy.check("ops", "secret_create", "db-host"),
2302            RbacDecision::Allow
2303        );
2304    }
2305
2306    // -- host_visible tests --
2307
2308    #[test]
2309    fn host_visible_respects_globs() {
2310        let policy = test_policy();
2311        assert!(policy.host_visible("deploy", "web-prod-1"));
2312        assert!(policy.host_visible("deploy", "api-staging"));
2313        assert!(!policy.host_visible("deploy", "db-prod-1"));
2314        assert!(policy.host_visible("ops", "anything"));
2315        assert!(policy.host_visible("viewer", "anything"));
2316    }
2317
2318    #[test]
2319    fn host_visible_unknown_role() {
2320        let policy = test_policy();
2321        assert!(!policy.host_visible("unknown", "web-prod-1"));
2322    }
2323
2324    #[test]
2325    fn host_matching_is_ascii_case_insensitive() {
2326        let policy = test_policy();
2327        assert!(policy.host_visible("deploy", "WEB-PROD-1"));
2328        assert!(policy.host_visible("deploy", "Web-Prod-1"));
2329        assert!(policy.host_visible("deploy", "API-Staging"));
2330        assert!(!policy.host_visible("deploy", "DB-PROD-1"));
2331    }
2332
2333    #[test]
2334    fn check_host_matching_is_ascii_case_insensitive() {
2335        let policy = test_policy();
2336        assert_eq!(
2337            policy.check("deploy", "resource_run", "WEB-PROD-1"),
2338            RbacDecision::Allow
2339        );
2340        assert_eq!(
2341            policy.check("deploy", "resource_run", "DB-PROD-1"),
2342            RbacDecision::Deny
2343        );
2344    }
2345
2346    #[test]
2347    fn check_operation_names_remain_case_sensitive() {
2348        let policy = test_policy();
2349        assert_eq!(
2350            policy.check("deploy", "RESOURCE_RUN", "web-prod-1"),
2351            RbacDecision::Deny,
2352            "host normalization must not leak into operation matching"
2353        );
2354    }
2355
2356    #[test]
2357    fn tool_glob_matching_remains_case_sensitive() {
2358        // Regression guard for the host-normalization change: lowercasing
2359        // inside `glob_match` would silently widen every tool allowlist.
2360        let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
2361            .with_argument_allowlists(vec![ArgumentAllowlist::new(
2362                "resource_*",
2363                "cmd",
2364                vec!["ls".into()],
2365            )]);
2366        let policy = RbacPolicy::new(&RbacConfig::with_roles(vec![role]));
2367
2368        assert!(policy.has_argument_allowlist("viewer", "resource_exec", "cmd"));
2369        assert!(
2370            !policy.has_argument_allowlist("viewer", "RESOURCE_EXEC", "cmd"),
2371            "tool patterns must not match case-insensitively"
2372        );
2373        assert!(!policy.argument_allowed("viewer", "resource_exec", "cmd", "rm"));
2374    }
2375
2376    // -- argument_allowed tests --
2377
2378    #[test]
2379    fn argument_allowed_no_allowlist() {
2380        let policy = test_policy();
2381        // ops has no argument_allowlists -- all values allowed
2382        assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "rm -rf /"));
2383        assert!(policy.argument_allowed("ops", "resource_exec", "cmd", "bash"));
2384    }
2385
2386    #[test]
2387    fn argument_allowed_with_allowlist() {
2388        let policy = test_policy();
2389        assert!(policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "sh"));
2390        assert!(policy.argument_allowed(
2391            "restricted-exec",
2392            "resource_exec",
2393            "cmd",
2394            "bash -c 'echo hi'"
2395        ));
2396        assert!(policy.argument_allowed(
2397            "restricted-exec",
2398            "resource_exec",
2399            "cmd",
2400            "cat /etc/hosts"
2401        ));
2402        assert!(policy.argument_allowed(
2403            "restricted-exec",
2404            "resource_exec",
2405            "cmd",
2406            "/usr/bin/ls -la"
2407        ));
2408    }
2409
2410    #[test]
2411    fn argument_denied_not_in_allowlist() {
2412        let policy = test_policy();
2413        assert!(!policy.argument_allowed("restricted-exec", "resource_exec", "cmd", "rm -rf /"));
2414        assert!(!policy.argument_allowed(
2415            "restricted-exec",
2416            "resource_exec",
2417            "cmd",
2418            "python3 exploit.py"
2419        ));
2420        assert!(!policy.argument_allowed(
2421            "restricted-exec",
2422            "resource_exec",
2423            "cmd",
2424            "/usr/bin/curl evil.com"
2425        ));
2426    }
2427
2428    #[test]
2429    fn argument_denied_unknown_role() {
2430        let policy = test_policy();
2431        assert!(!policy.argument_allowed("unknown", "resource_exec", "cmd", "sh"));
2432    }
2433
2434    // -- M7: strict argument confinement (`deny_unknown_arguments`) --
2435
2436    fn strict_test_policy(allowlists: Vec<ArgumentAllowlist>) -> RbacPolicy {
2437        let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2438            .with_argument_allowlists(allowlists);
2439        let mut config = RbacConfig::with_roles(vec![role]);
2440        config.enabled = true;
2441        RbacPolicy::new(&config)
2442    }
2443
2444    fn tool_call(args: serde_json::Value) -> serde_json::Value {
2445        let mut params = serde_json::Map::new();
2446        params.insert(
2447            "name".to_owned(),
2448            serde_json::Value::String("run".to_owned()),
2449        );
2450        params.insert("arguments".to_owned(), args);
2451        serde_json::Value::Object(params)
2452    }
2453
2454    #[test]
2455    fn unknown_arguments_are_admitted_when_strict_mode_is_off() {
2456        let policy = strict_test_policy(vec![ArgumentAllowlist::new(
2457            "run",
2458            "cmd",
2459            vec!["ls".into()],
2460        )]);
2461        let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2462        assert!(
2463            enforce_tool_policy(&policy, "u", "viewer", &params).is_none(),
2464            "default behaviour must be unchanged: unnamed arguments pass"
2465        );
2466    }
2467
2468    #[test]
2469    fn strict_mode_rejects_unknown_arguments() {
2470        let policy = strict_test_policy(vec![
2471            ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2472                .with_deny_unknown_arguments(true),
2473        ]);
2474        let params = tool_call(serde_json::json!({ "cmd": "ls", "danger": true }));
2475        assert!(
2476            enforce_tool_policy(&policy, "u", "viewer", &params).is_some(),
2477            "an argument no allowlist names must be denied under strict mode"
2478        );
2479
2480        let permitted = tool_call(serde_json::json!({ "cmd": "ls" }));
2481        assert!(
2482            enforce_tool_policy(&policy, "u", "viewer", &permitted).is_none(),
2483            "an allowlisted argument must still pass"
2484        );
2485    }
2486
2487    #[test]
2488    fn strict_mode_rejects_structured_argument_values() {
2489        let policy = strict_test_policy(vec![
2490            ArgumentAllowlist::new("run", "cmd", vec![]).with_deny_unknown_arguments(true),
2491        ]);
2492        for shape in [
2493            serde_json::json!({ "nested": "x" }),
2494            serde_json::json!(["x"]),
2495        ] {
2496            let params = tool_call(serde_json::json!({ "cmd": shape }));
2497            assert!(
2498                enforce_tool_policy(&policy, "u", "viewer", &params).is_some(),
2499                "object/array values cannot be constrained and must be denied"
2500            );
2501        }
2502    }
2503
2504    #[test]
2505    fn strict_mode_permits_the_union_of_matching_allowlists() {
2506        // Only the first entry sets the flag, yet both arguments stay usable:
2507        // strict mode confines the whole `(role, tool)` pair, not one entry.
2508        let policy = strict_test_policy(vec![
2509            ArgumentAllowlist::new("run", "cmd", vec!["ls".into()])
2510                .with_deny_unknown_arguments(true),
2511            ArgumentAllowlist::new("run", "host", vec![]),
2512        ]);
2513        let params = tool_call(serde_json::json!({ "cmd": "ls", "host": "dev-1" }));
2514        assert!(
2515            enforce_tool_policy(&policy, "u", "viewer", &params).is_none(),
2516            "every matching allowlist's argument must remain permitted"
2517        );
2518    }
2519
2520    // -- shlex-tokenization regression tests (1.4.1) --
2521    //
2522    // These tests pin the POSIX-shell-like tokenization contract added
2523    // in 1.4.1. See `RbacPolicy::argument_allowed` doc comment for the
2524    // full contract; see CHANGELOG.md `[1.4.1]` for the behavior matrix.
2525
2526    /// Helper: build a minimal enabled policy with a single argument
2527    /// allowlist on tool `run`, argument `cmd`.
2528    fn shlex_policy(allowed: Vec<String>) -> RbacPolicy {
2529        let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
2530            .with_argument_allowlists(vec![ArgumentAllowlist::new("run", "cmd", allowed)]);
2531        let mut config = RbacConfig::with_roles(vec![role]);
2532        config.enabled = true;
2533        RbacPolicy::new(&config)
2534    }
2535
2536    #[test]
2537    fn argument_allowed_matches_quoted_path_with_spaces() {
2538        let policy = shlex_policy(vec!["/usr/bin/my tool".into()]);
2539        assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2540    }
2541
2542    #[test]
2543    fn argument_allowed_matches_basename_of_quoted_path() {
2544        let policy = shlex_policy(vec!["my tool".into()]);
2545        assert!(policy.argument_allowed("viewer", "run", "cmd", r#""/usr/bin/my tool" --flag"#));
2546    }
2547
2548    #[test]
2549    fn argument_allowed_fails_closed_on_unbalanced_quote() {
2550        let policy = shlex_policy(vec!["unbalanced".into()]);
2551        assert!(!policy.argument_allowed("viewer", "run", "cmd", r"unbalanced 'quote"));
2552    }
2553
2554    #[test]
2555    fn argument_allowed_fails_closed_on_empty_string() {
2556        let policy = shlex_policy(vec![String::new()]);
2557        assert!(!policy.argument_allowed("viewer", "run", "cmd", ""));
2558    }
2559
2560    #[test]
2561    fn argument_allowed_handles_single_quoted_executable() {
2562        let policy = shlex_policy(vec!["/bin/sh".into()]);
2563        assert!(policy.argument_allowed("viewer", "run", "cmd", r"'/bin/sh' -c 'echo hi'"));
2564    }
2565
2566    #[test]
2567    fn argument_allowed_handles_tab_separator() {
2568        let policy = shlex_policy(vec!["ls".into()]);
2569        assert!(policy.argument_allowed("viewer", "run", "cmd", "ls\t/etc/passwd"));
2570    }
2571
2572    #[test]
2573    fn argument_allowed_plain_token_unchanged() {
2574        let policy = shlex_policy(vec!["ls".into()]);
2575        assert!(policy.argument_allowed("viewer", "run", "cmd", "ls"));
2576    }
2577
2578    // Per Oracle review: the next four tests pin the cases the original
2579    // handoff missed. Each confirms the *new* (1.4.1) deny behavior so a
2580    // future regression to the old `split_whitespace` semantics would
2581    // surface as a test failure.
2582
2583    #[test]
2584    fn argument_allowed_fails_closed_on_quoted_empty_first_token() {
2585        // value r#""""# parses to Some(vec![""]). An empty argv element
2586        // is never a runnable executable; deny even when "" is
2587        // explicitly allowlisted.
2588        let policy = shlex_policy(vec![String::new()]);
2589        assert!(!policy.argument_allowed("viewer", "run", "cmd", r#""""#));
2590    }
2591
2592    #[test]
2593    fn argument_allowed_quoted_literal_token_no_longer_matches() {
2594        // 1.4.0 behavior: split_whitespace first token = "'bash'" --
2595        //                 matched literal allowlist entry "'bash'".
2596        // 1.4.1 behavior: shlex strips the surrounding quotes -> first
2597        //                 token = "bash" -- no match against allowlist
2598        //                 entry "'bash'". Deny.
2599        let policy = shlex_policy(vec!["'bash'".into()]);
2600        assert!(!policy.argument_allowed("viewer", "run", "cmd", "'bash' -c true"));
2601    }
2602
2603    #[test]
2604    fn argument_allowed_backslash_literal_token_no_longer_matches() {
2605        // 1.4.0 behavior: literal first token "foo\\bar" matched.
2606        // 1.4.1 behavior: POSIX shlex treats backslash as escape ->
2607        //                 first token = "foobar". Allowlist entry with
2608        //                 a literal backslash no longer matches. Deny.
2609        let policy = shlex_policy(vec![r"foo\bar".into()]);
2610        assert!(!policy.argument_allowed("viewer", "run", "cmd", r"foo\bar --x"));
2611    }
2612
2613    #[test]
2614    fn argument_allowed_windows_path_no_longer_matches() {
2615        // 1.4.0 behavior: literal Windows path matched.
2616        // 1.4.1 behavior: POSIX shlex eats backslashes -> path identity
2617        //                 changes; allowlist entry no longer matches.
2618        //                 Deny. Documented in CHANGELOG operator notes.
2619        let policy = shlex_policy(vec![r"C:\Windows\System32\cmd.exe".into()]);
2620        assert!(!policy.argument_allowed(
2621            "viewer",
2622            "run",
2623            "cmd",
2624            r"C:\Windows\System32\cmd.exe /c dir"
2625        ));
2626    }
2627
2628    // -- host_patterns tests --
2629
2630    #[test]
2631    fn host_patterns_returns_globs() {
2632        let policy = test_policy();
2633        assert_eq!(
2634            policy.host_patterns("deploy"),
2635            Some(vec!["web-*".to_owned(), "api-*".to_owned()].as_slice())
2636        );
2637        assert_eq!(
2638            policy.host_patterns("ops"),
2639            Some(vec!["*".to_owned()].as_slice())
2640        );
2641        assert!(policy.host_patterns("nonexistent").is_none());
2642    }
2643
2644    // -- check_operation tests (no host check) --
2645
2646    #[test]
2647    fn check_operation_allows_without_host() {
2648        let policy = test_policy();
2649        assert_eq!(
2650            policy.check_operation("deploy", "resource_run"),
2651            RbacDecision::Allow
2652        );
2653        // but check() with a non-matching host denies
2654        assert_eq!(
2655            policy.check("deploy", "resource_run", "db-prod-1"),
2656            RbacDecision::Deny
2657        );
2658    }
2659
2660    #[test]
2661    fn check_operation_deny_overrides() {
2662        let policy = test_policy();
2663        assert_eq!(
2664            policy.check_operation("deploy", "resource_delete"),
2665            RbacDecision::Deny
2666        );
2667    }
2668
2669    #[test]
2670    fn check_operation_unknown_role() {
2671        let policy = test_policy();
2672        assert_eq!(
2673            policy.check_operation("unknown", "resource_list"),
2674            RbacDecision::Deny
2675        );
2676    }
2677
2678    #[test]
2679    fn check_operation_disabled() {
2680        let policy = RbacPolicy::new(&RbacConfig {
2681            enabled: false,
2682            roles: vec![],
2683            redaction_salt: None,
2684            ..RbacConfig::default()
2685        });
2686        assert_eq!(
2687            policy.check_operation("nonexistent", "anything"),
2688            RbacDecision::Allow
2689        );
2690    }
2691
2692    // -- operation glob matching / global_deny tests --
2693
2694    fn op_policy(role: RoleConfig) -> RbacPolicy {
2695        RbacPolicy::new(&RbacConfig::with_roles(vec![role]))
2696    }
2697
2698    fn glob_op_policy(role: RoleConfig) -> RbacPolicy {
2699        RbacPolicy::new(
2700            &RbacConfig::with_roles(vec![role])
2701                .with_allow_operation_matching(AllowOperationMatching::Glob),
2702        )
2703    }
2704
2705    #[test]
2706    fn deny_glob_blocks_under_allow_all() {
2707        let policy = op_policy(
2708            RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2709                .with_deny(vec!["*_delete_*".into()]),
2710        );
2711        assert_eq!(
2712            policy.check_operation("editor", "jira_delete_issue"),
2713            RbacDecision::Deny
2714        );
2715        assert_eq!(
2716            policy.check_operation("editor", "confluence_delete_page"),
2717            RbacDecision::Deny
2718        );
2719        assert_eq!(
2720            policy.check_operation("editor", "jira_get_issue"),
2721            RbacDecision::Allow
2722        );
2723    }
2724
2725    #[test]
2726    fn deny_glob_blocks_in_host_scoped_check() {
2727        let policy = op_policy(
2728            RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2729                .with_deny(vec!["jira_delete_*".into()]),
2730        );
2731        assert_eq!(
2732            policy.check("editor", "jira_delete_issue", "web-prod"),
2733            RbacDecision::Deny
2734        );
2735        assert_eq!(
2736            policy.check("editor", "jira_get_issue", "web-prod"),
2737            RbacDecision::Allow
2738        );
2739    }
2740
2741    #[test]
2742    fn deny_without_glob_still_matches_exactly() {
2743        let policy = op_policy(
2744            RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2745                .with_deny(vec!["delete".into()]),
2746        );
2747        assert_eq!(
2748            policy.check_operation("editor", "delete"),
2749            RbacDecision::Deny
2750        );
2751        assert_eq!(
2752            policy.check_operation("editor", "delete_thing"),
2753            RbacDecision::Allow
2754        );
2755        assert_eq!(
2756            policy.check_operation("editor", "soft_delete"),
2757            RbacDecision::Allow
2758        );
2759    }
2760
2761    #[test]
2762    fn allow_glob_is_inert_in_legacy_mode() {
2763        let policy = op_policy(RoleConfig::new(
2764            "reader",
2765            vec!["jira_get_*".into()],
2766            vec!["*".into()],
2767        ));
2768        assert_eq!(
2769            policy.check_operation("reader", "jira_get_issue"),
2770            RbacDecision::Deny
2771        );
2772        assert_eq!(
2773            policy.check_operation("reader", "jira_get_*"),
2774            RbacDecision::Allow
2775        );
2776    }
2777
2778    #[test]
2779    fn allow_glob_is_honored_in_glob_mode() {
2780        let policy = glob_op_policy(RoleConfig::new(
2781            "reader",
2782            vec!["jira_get_*".into()],
2783            vec!["*".into()],
2784        ));
2785        assert_eq!(
2786            policy.check_operation("reader", "jira_get_issue"),
2787            RbacDecision::Allow
2788        );
2789        assert_eq!(
2790            policy.check_operation("reader", "confluence_get_page"),
2791            RbacDecision::Deny
2792        );
2793    }
2794
2795    #[test]
2796    fn allow_glob_mode_preserves_case_sensitivity() {
2797        let policy = glob_op_policy(RoleConfig::new(
2798            "reader",
2799            vec!["Jira_*".into()],
2800            vec!["*".into()],
2801        ));
2802        assert_eq!(
2803            policy.check_operation("reader", "jira_get_issue"),
2804            RbacDecision::Deny
2805        );
2806        assert_eq!(
2807            policy.check_operation("reader", "Jira_get_issue"),
2808            RbacDecision::Allow
2809        );
2810    }
2811
2812    #[test]
2813    fn allow_exact_entries_behave_identically_in_both_modes() {
2814        let role = RoleConfig::new(
2815            "reader",
2816            vec!["ping".into(), "list_hosts".into()],
2817            vec!["*".into()],
2818        );
2819        let legacy = op_policy(role.clone());
2820        let glob = glob_op_policy(role);
2821        for op in ["ping", "list_hosts", "delete", "pin", "pingg"] {
2822            assert_eq!(
2823                legacy.check_operation("reader", op),
2824                glob.check_operation("reader", op),
2825                "mode divergence on glob-free allow entry for {op}"
2826            );
2827        }
2828    }
2829
2830    #[test]
2831    fn allow_star_means_all_operations_in_both_modes() {
2832        let role = RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]);
2833        for policy in [op_policy(role.clone()), glob_op_policy(role)] {
2834            assert_eq!(
2835                policy.check_operation("admin", "anything_at_all"),
2836                RbacDecision::Allow
2837            );
2838        }
2839    }
2840
2841    #[test]
2842    fn global_deny_vetoes_allow_all() {
2843        let policy = RbacPolicy::new(
2844            &RbacConfig::with_roles(vec![RoleConfig::new(
2845                "admin",
2846                vec!["*".into()],
2847                vec!["*".into()],
2848            )])
2849            .with_global_deny(vec!["*_delete_*".into()]),
2850        );
2851        assert_eq!(
2852            policy.check_operation("admin", "jira_delete_issue"),
2853            RbacDecision::Deny
2854        );
2855        assert_eq!(
2856            policy.check("admin", "jira_delete_issue", "web-prod"),
2857            RbacDecision::Deny
2858        );
2859        assert_eq!(
2860            policy.check_operation("admin", "jira_get_issue"),
2861            RbacDecision::Allow
2862        );
2863    }
2864
2865    #[test]
2866    fn global_deny_globs_even_in_legacy_allow_mode() {
2867        let policy = RbacPolicy::new(
2868            &RbacConfig::with_roles(vec![RoleConfig::new(
2869                "admin",
2870                vec!["*".into()],
2871                vec!["*".into()],
2872            )])
2873            .with_allow_operation_matching(AllowOperationMatching::Legacy)
2874            .with_global_deny(vec!["danger_*".into()]),
2875        );
2876        assert_eq!(
2877            policy.check_operation("admin", "danger_wipe"),
2878            RbacDecision::Deny
2879        );
2880    }
2881
2882    #[test]
2883    fn global_deny_is_inert_when_rbac_disabled() {
2884        let policy = RbacPolicy::new(&RbacConfig {
2885            enabled: false,
2886            global_deny: vec!["*".into()],
2887            ..RbacConfig::default()
2888        });
2889        assert_eq!(
2890            policy.check_operation("anyone", "anything"),
2891            RbacDecision::Allow
2892        );
2893    }
2894
2895    #[test]
2896    fn global_deny_defaults_to_empty_and_changes_nothing() {
2897        let policy = op_policy(RoleConfig::new("admin", vec!["*".into()], vec!["*".into()]));
2898        assert_eq!(
2899            policy.check_operation("admin", "jira_delete_issue"),
2900            RbacDecision::Allow
2901        );
2902        assert_eq!(policy.summary().global_deny, 0);
2903    }
2904
2905    #[test]
2906    fn empty_deny_entry_denies_only_the_empty_operation() {
2907        let policy = op_policy(
2908            RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2909                .with_deny(vec![String::new()]),
2910        );
2911        assert_eq!(policy.check_operation("editor", ""), RbacDecision::Deny);
2912        assert_eq!(
2913            policy.check_operation("editor", "anything"),
2914            RbacDecision::Allow
2915        );
2916    }
2917
2918    #[test]
2919    fn empty_global_deny_entry_denies_only_the_empty_operation() {
2920        let policy = RbacPolicy::new(
2921            &RbacConfig::with_roles(vec![RoleConfig::new(
2922                "admin",
2923                vec!["*".into()],
2924                vec!["*".into()],
2925            )])
2926            .with_global_deny(vec![String::new()]),
2927        );
2928        assert_eq!(policy.check_operation("admin", ""), RbacDecision::Deny);
2929        assert_eq!(
2930            policy.check_operation("admin", "anything"),
2931            RbacDecision::Allow
2932        );
2933    }
2934
2935    #[test]
2936    fn star_deny_entry_denies_every_operation() {
2937        let policy = op_policy(
2938            RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2939                .with_deny(vec!["*".into()]),
2940        );
2941        for op in ["", "ping", "jira_delete_issue"] {
2942            assert_eq!(policy.check_operation("editor", op), RbacDecision::Deny);
2943            assert_eq!(policy.check("editor", op, "web-prod"), RbacDecision::Deny);
2944        }
2945    }
2946
2947    #[test]
2948    fn star_global_deny_entry_denies_every_operation() {
2949        let policy = RbacPolicy::new(
2950            &RbacConfig::with_roles(vec![RoleConfig::new(
2951                "admin",
2952                vec!["*".into()],
2953                vec!["*".into()],
2954            )])
2955            .with_global_deny(vec!["*".into()]),
2956        );
2957        for op in ["", "ping", "jira_delete_issue"] {
2958            assert_eq!(policy.check_operation("admin", op), RbacDecision::Deny);
2959        }
2960    }
2961
2962    #[test]
2963    fn legacy_allow_matches_a_literal_star_in_an_operation_name() {
2964        let policy = op_policy(RoleConfig::new(
2965            "odd",
2966            vec!["weird_*_name".into()],
2967            vec!["*".into()],
2968        ));
2969        assert_eq!(
2970            policy.check_operation("odd", "weird_*_name"),
2971            RbacDecision::Allow
2972        );
2973        assert_eq!(
2974            policy.check_operation("odd", "weird_thing_name"),
2975            RbacDecision::Deny
2976        );
2977    }
2978
2979    #[test]
2980    fn deny_glob_matches_multibyte_operation_names() {
2981        let policy = op_policy(
2982            RoleConfig::new("editor", vec!["*".into()], vec!["*".into()])
2983                .with_deny(vec!["削除_*".into()]),
2984        );
2985        assert_eq!(
2986            policy.check_operation("editor", "削除_ページ"),
2987            RbacDecision::Deny
2988        );
2989        assert_eq!(
2990            policy.check_operation("editor", "取得_ページ"),
2991            RbacDecision::Allow
2992        );
2993    }
2994
2995    #[test]
2996    fn operation_matching_fields_deserialize_from_toml() {
2997        let cfg: RbacConfig = toml::from_str(
2998            r#"
2999            enabled = true
3000            allow_operation_matching = "glob"
3001            global_deny = ["*_purge_*"]
3002
3003            [[roles]]
3004            name = "ops"
3005            allow = ["jira_*"]
3006            hosts = ["*"]
3007            "#,
3008        )
3009        .expect("config parses");
3010        assert_eq!(
3011            cfg.allow_operation_matching,
3012            AllowOperationMatching::Glob,
3013            "kebab-case wire value must map to the Glob variant"
3014        );
3015        assert_eq!(cfg.global_deny, vec!["*_purge_*".to_owned()]);
3016
3017        let policy = RbacPolicy::new(&cfg);
3018        assert_eq!(
3019            policy.check_operation("ops", "jira_get_issue"),
3020            RbacDecision::Allow
3021        );
3022        assert_eq!(
3023            policy.check_operation("ops", "jira_purge_project"),
3024            RbacDecision::Deny
3025        );
3026    }
3027
3028    #[test]
3029    fn operation_matching_defaults_to_legacy_when_absent_from_toml() {
3030        let cfg: RbacConfig = toml::from_str("enabled = true").expect("config parses");
3031        assert_eq!(cfg.allow_operation_matching, AllowOperationMatching::Legacy);
3032        assert!(cfg.global_deny.is_empty());
3033    }
3034
3035    // -- current_role / current_identity tests --
3036
3037    #[test]
3038    fn current_role_returns_none_outside_scope() {
3039        assert!(current_role().is_none());
3040    }
3041
3042    #[test]
3043    fn current_identity_returns_none_outside_scope() {
3044        assert!(current_identity().is_none());
3045    }
3046
3047    #[tokio::test]
3048    async fn empty_task_locals_are_all_absent() {
3049        with_rbac_scope(
3050            String::new(),
3051            String::new(),
3052            SecretString::from(String::new()),
3053            String::new(),
3054            async {
3055                assert!(current_role().is_none(), "empty role must be absent");
3056                assert!(
3057                    current_identity().is_none(),
3058                    "empty identity must be absent"
3059                );
3060                assert!(current_token().is_none(), "empty token must be absent");
3061                assert!(current_sub().is_none(), "empty sub must be absent");
3062            },
3063        )
3064        .await;
3065    }
3066
3067    #[tokio::test]
3068    async fn non_empty_task_locals_are_all_present() {
3069        with_rbac_scope(
3070            "viewer".to_owned(),
3071            "alice".to_owned(),
3072            SecretString::from("tok".to_owned()),
3073            "sub-1".to_owned(),
3074            async {
3075                assert_eq!(current_role().as_deref(), Some("viewer"));
3076                assert_eq!(current_identity().as_deref(), Some("alice"));
3077                assert!(current_token().is_some());
3078                assert_eq!(current_sub().as_deref(), Some("sub-1"));
3079            },
3080        )
3081        .await;
3082    }
3083
3084    /// The reachable shape from the downstream report: a real role with an
3085    /// empty identity and no sub. `current_sub().or_else(current_identity)`
3086    /// must resolve to `None` so a caller's `ok_or_else` guard fires, rather
3087    /// than yielding `Some("")` and being used as a per-user key.
3088    #[tokio::test]
3089    async fn sub_or_identity_fallback_is_absent_for_empty_identity() {
3090        with_rbac_scope(
3091            "viewer".to_owned(),
3092            String::new(),
3093            SecretString::from(String::new()),
3094            String::new(),
3095            async {
3096                assert_eq!(current_role().as_deref(), Some("viewer"));
3097                assert!(
3098                    current_sub().or_else(current_identity).is_none(),
3099                    "empty identity must not satisfy a sub-or-identity fallback"
3100                );
3101            },
3102        )
3103        .await;
3104    }
3105
3106    // -- rbac_middleware integration tests --
3107
3108    use axum::{
3109        body::Body,
3110        http::{Method, Request, StatusCode},
3111    };
3112    use tower::ServiceExt as _;
3113
3114    fn tool_call_body(tool: &str, args: &serde_json::Value) -> String {
3115        serde_json::json!({
3116            "jsonrpc": "2.0",
3117            "id": 1,
3118            "method": "tools/call",
3119            "params": {
3120                "name": tool,
3121                "arguments": args
3122            }
3123        })
3124        .to_string()
3125    }
3126
3127    fn rbac_router(policy: Arc<RbacPolicy>) -> axum::Router {
3128        axum::Router::new()
3129            .route("/mcp", axum::routing::post(|| async { "ok" }))
3130            .layer(axum::middleware::from_fn(move |req, next| {
3131                let p = Arc::clone(&policy);
3132                rbac_middleware(p, None, req, next)
3133            }))
3134    }
3135
3136    fn rbac_router_with_identity(policy: Arc<RbacPolicy>, identity: AuthIdentity) -> axum::Router {
3137        axum::Router::new()
3138            .route("/mcp", axum::routing::post(|| async { "ok" }))
3139            .layer(axum::middleware::from_fn(
3140                move |mut req: Request<Body>, next: Next| {
3141                    let p = Arc::clone(&policy);
3142                    let id = identity.clone();
3143                    async move {
3144                        req.extensions_mut().insert(id);
3145                        rbac_middleware(p, None, req, next).await
3146                    }
3147                },
3148            ))
3149    }
3150
3151    /// Tool-limiter deny path must increment the `tool` deny counter via
3152    /// the metrics handle in the request extensions - and the increment
3153    /// must survive the middleware's body-buffer/`from_parts` rebuild.
3154    #[cfg(feature = "metrics")]
3155    #[tokio::test]
3156    async fn tool_limiter_deny_increments_counter() {
3157        use axum::extract::ConnectInfo;
3158
3159        let policy = Arc::new(test_policy());
3160        let limiter = build_tool_rate_limiter_with_policy(1, None, KeyEvictionPolicy::default());
3161        let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
3162        let identity = AuthIdentity {
3163            method: crate::auth::AuthMethod::BearerToken,
3164            name: "alice".into(),
3165            role: "viewer".into(),
3166            raw_token: None,
3167            sub: None,
3168        };
3169        let app = {
3170            let metrics = Arc::clone(&metrics);
3171            axum::Router::new()
3172                .route("/mcp", axum::routing::post(|| async { "ok" }))
3173                .layer(axum::middleware::from_fn(
3174                    move |mut req: Request<Body>, next: Next| {
3175                        let p = Arc::clone(&policy);
3176                        let l = Arc::clone(&limiter);
3177                        let id = identity.clone();
3178                        let m = Arc::clone(&metrics);
3179                        async move {
3180                            req.extensions_mut().insert(id);
3181                            req.extensions_mut().insert(m);
3182                            let peer: std::net::SocketAddr =
3183                                "10.9.9.1:40000".parse().expect("static socket addr parses");
3184                            req.extensions_mut().insert(ConnectInfo(peer));
3185                            rbac_middleware(p, Some(l), req, next).await
3186                        }
3187                    },
3188                ))
3189        };
3190        let mk = || {
3191            Request::builder()
3192                .method(Method::POST)
3193                .uri("/mcp")
3194                .header("content-type", "application/json")
3195                .body(Body::from(tool_call_body(
3196                    "resource_list",
3197                    &serde_json::json!({}),
3198                )))
3199                .unwrap()
3200        };
3201        let counter = || {
3202            metrics
3203                .rate_limited_total
3204                .with_label_values(&["tool"])
3205                .get()
3206        };
3207
3208        let first = app.clone().oneshot(mk()).await.unwrap();
3209        assert_eq!(first.status(), StatusCode::OK);
3210        assert_eq!(counter(), 0, "successful call must not count");
3211
3212        let denied = app.clone().oneshot(mk()).await.unwrap();
3213        assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
3214        assert_eq!(counter(), 1, "deny must increment the tool label");
3215    }
3216
3217    #[tokio::test]
3218    async fn middleware_passes_non_post() {
3219        let policy = Arc::new(test_policy());
3220        let app = rbac_router(policy);
3221        // GET passes through even without identity.
3222        let req = Request::builder()
3223            .method(Method::GET)
3224            .uri("/mcp")
3225            .body(Body::empty())
3226            .unwrap();
3227        // GET on a POST-only route returns 405, but the middleware itself
3228        // doesn't block it -- it returns next.run(req).
3229        let resp = app.oneshot(req).await.unwrap();
3230        assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
3231    }
3232
3233    #[tokio::test]
3234    async fn middleware_denies_without_identity() {
3235        let policy = Arc::new(test_policy());
3236        let app = rbac_router(policy);
3237        let body = tool_call_body("resource_list", &serde_json::json!({}));
3238        let req = Request::builder()
3239            .method(Method::POST)
3240            .uri("/mcp")
3241            .header("content-type", "application/json")
3242            .body(Body::from(body))
3243            .unwrap();
3244        let resp = app.oneshot(req).await.unwrap();
3245        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3246    }
3247
3248    fn global_deny_identity() -> AuthIdentity {
3249        AuthIdentity {
3250            method: crate::auth::AuthMethod::BearerToken,
3251            name: "alice".into(),
3252            role: "admin".into(),
3253            raw_token: None,
3254            sub: None,
3255        }
3256    }
3257
3258    fn global_deny_policy() -> Arc<RbacPolicy> {
3259        Arc::new(RbacPolicy::new(
3260            &RbacConfig::with_roles(vec![RoleConfig::new(
3261                "admin",
3262                vec!["*".into()],
3263                vec!["*".into()],
3264            )])
3265            .with_global_deny(vec!["*_delete_*".into()]),
3266        ))
3267    }
3268
3269    async fn global_deny_call(args: serde_json::Value, tool: &str) -> StatusCode {
3270        let app = rbac_router_with_identity(global_deny_policy(), global_deny_identity());
3271        let req = Request::builder()
3272            .method(Method::POST)
3273            .uri("/mcp")
3274            .header("content-type", "application/json")
3275            .body(Body::from(tool_call_body(tool, &args)))
3276            .unwrap();
3277        app.oneshot(req).await.unwrap().status()
3278    }
3279
3280    #[tokio::test]
3281    async fn middleware_global_deny_blocks_hostless_tool_call() {
3282        assert_eq!(
3283            global_deny_call(serde_json::json!({}), "jira_delete_issue").await,
3284            StatusCode::FORBIDDEN
3285        );
3286        assert_eq!(
3287            global_deny_call(serde_json::json!({}), "jira_get_issue").await,
3288            StatusCode::OK
3289        );
3290    }
3291
3292    #[tokio::test]
3293    async fn middleware_global_deny_blocks_host_scoped_tool_call() {
3294        assert_eq!(
3295            global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_delete_issue").await,
3296            StatusCode::FORBIDDEN
3297        );
3298        assert_eq!(
3299            global_deny_call(serde_json::json!({"host": "web-prod"}), "jira_get_issue").await,
3300            StatusCode::OK
3301        );
3302    }
3303
3304    #[tokio::test]
3305    async fn middleware_allows_permitted_tool() {
3306        let policy = Arc::new(test_policy());
3307        let id = AuthIdentity {
3308            method: crate::auth::AuthMethod::BearerToken,
3309            name: "alice".into(),
3310            role: "viewer".into(),
3311            raw_token: None,
3312            sub: None,
3313        };
3314        let app = rbac_router_with_identity(policy, id);
3315        let body = tool_call_body("resource_list", &serde_json::json!({}));
3316        let req = Request::builder()
3317            .method(Method::POST)
3318            .uri("/mcp")
3319            .header("content-type", "application/json")
3320            .body(Body::from(body))
3321            .unwrap();
3322        let resp = app.oneshot(req).await.unwrap();
3323        assert_eq!(resp.status(), StatusCode::OK);
3324    }
3325
3326    #[tokio::test]
3327    async fn middleware_denies_unpermitted_tool() {
3328        let policy = Arc::new(test_policy());
3329        let id = AuthIdentity {
3330            method: crate::auth::AuthMethod::BearerToken,
3331            name: "alice".into(),
3332            role: "viewer".into(),
3333            raw_token: None,
3334            sub: None,
3335        };
3336        let app = rbac_router_with_identity(policy, id);
3337        let body = tool_call_body("resource_delete", &serde_json::json!({}));
3338        let req = Request::builder()
3339            .method(Method::POST)
3340            .uri("/mcp")
3341            .header("content-type", "application/json")
3342            .body(Body::from(body))
3343            .unwrap();
3344        let resp = app.oneshot(req).await.unwrap();
3345        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3346    }
3347
3348    #[tokio::test]
3349    async fn middleware_passes_non_tool_call_post() {
3350        let policy = Arc::new(test_policy());
3351        let id = AuthIdentity {
3352            method: crate::auth::AuthMethod::BearerToken,
3353            name: "alice".into(),
3354            role: "viewer".into(),
3355            raw_token: None,
3356            sub: None,
3357        };
3358        let app = rbac_router_with_identity(policy, id);
3359        // A non-tools/call JSON-RPC (e.g. resources/list) passes through.
3360        let body = serde_json::json!({
3361            "jsonrpc": "2.0",
3362            "id": 1,
3363            "method": "resources/list"
3364        })
3365        .to_string();
3366        let req = Request::builder()
3367            .method(Method::POST)
3368            .uri("/mcp")
3369            .header("content-type", "application/json")
3370            .body(Body::from(body))
3371            .unwrap();
3372        let resp = app.oneshot(req).await.unwrap();
3373        assert_eq!(resp.status(), StatusCode::OK);
3374    }
3375
3376    #[tokio::test]
3377    async fn middleware_enforces_argument_allowlist() {
3378        let policy = Arc::new(test_policy());
3379        let id = AuthIdentity {
3380            method: crate::auth::AuthMethod::BearerToken,
3381            name: "dev".into(),
3382            role: "restricted-exec".into(),
3383            raw_token: None,
3384            sub: None,
3385        };
3386        // Allowed command
3387        let app = rbac_router_with_identity(Arc::clone(&policy), id.clone());
3388        let body = tool_call_body(
3389            "resource_exec",
3390            &serde_json::json!({"cmd": "ls -la", "host": "dev-1"}),
3391        );
3392        let req = Request::builder()
3393            .method(Method::POST)
3394            .uri("/mcp")
3395            .body(Body::from(body))
3396            .unwrap();
3397        let resp = app.oneshot(req).await.unwrap();
3398        assert_eq!(resp.status(), StatusCode::OK);
3399
3400        // Denied command
3401        let app = rbac_router_with_identity(policy, id);
3402        let body = tool_call_body(
3403            "resource_exec",
3404            &serde_json::json!({"cmd": "rm -rf /", "host": "dev-1"}),
3405        );
3406        let req = Request::builder()
3407            .method(Method::POST)
3408            .uri("/mcp")
3409            .body(Body::from(body))
3410            .unwrap();
3411        let resp = app.oneshot(req).await.unwrap();
3412        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3413    }
3414
3415    #[tokio::test]
3416    async fn middleware_disabled_policy_passes_everything() {
3417        let policy = Arc::new(RbacPolicy::disabled());
3418        let app = rbac_router(policy);
3419        // No identity, disabled policy -- should pass.
3420        let body = tool_call_body("anything", &serde_json::json!({}));
3421        let req = Request::builder()
3422            .method(Method::POST)
3423            .uri("/mcp")
3424            .body(Body::from(body))
3425            .unwrap();
3426        let resp = app.oneshot(req).await.unwrap();
3427        assert_eq!(resp.status(), StatusCode::OK);
3428    }
3429
3430    #[tokio::test]
3431    async fn middleware_batch_all_allowed_passes() {
3432        let policy = Arc::new(test_policy());
3433        let id = AuthIdentity {
3434            method: crate::auth::AuthMethod::BearerToken,
3435            name: "alice".into(),
3436            role: "viewer".into(),
3437            raw_token: None,
3438            sub: None,
3439        };
3440        let app = rbac_router_with_identity(policy, id);
3441        let body = serde_json::json!([
3442            {
3443                "jsonrpc": "2.0",
3444                "id": 1,
3445                "method": "tools/call",
3446                "params": { "name": "resource_list", "arguments": {} }
3447            },
3448            {
3449                "jsonrpc": "2.0",
3450                "id": 2,
3451                "method": "tools/call",
3452                "params": { "name": "system_info", "arguments": {} }
3453            }
3454        ])
3455        .to_string();
3456        let req = Request::builder()
3457            .method(Method::POST)
3458            .uri("/mcp")
3459            .header("content-type", "application/json")
3460            .body(Body::from(body))
3461            .unwrap();
3462        let resp = app.oneshot(req).await.unwrap();
3463        assert_eq!(resp.status(), StatusCode::OK);
3464    }
3465
3466    #[tokio::test]
3467    async fn middleware_batch_with_denied_call_rejects_entire_batch() {
3468        let policy = Arc::new(test_policy());
3469        let id = AuthIdentity {
3470            method: crate::auth::AuthMethod::BearerToken,
3471            name: "alice".into(),
3472            role: "viewer".into(),
3473            raw_token: None,
3474            sub: None,
3475        };
3476        let app = rbac_router_with_identity(policy, id);
3477        let body = serde_json::json!([
3478            {
3479                "jsonrpc": "2.0",
3480                "id": 1,
3481                "method": "tools/call",
3482                "params": { "name": "resource_list", "arguments": {} }
3483            },
3484            {
3485                "jsonrpc": "2.0",
3486                "id": 2,
3487                "method": "tools/call",
3488                "params": { "name": "resource_delete", "arguments": {} }
3489            }
3490        ])
3491        .to_string();
3492        let req = Request::builder()
3493            .method(Method::POST)
3494            .uri("/mcp")
3495            .header("content-type", "application/json")
3496            .body(Body::from(body))
3497            .unwrap();
3498        let resp = app.oneshot(req).await.unwrap();
3499        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3500    }
3501
3502    #[tokio::test]
3503    async fn middleware_batch_mixed_allowed_and_denied_rejects() {
3504        let policy = Arc::new(test_policy());
3505        let id = AuthIdentity {
3506            method: crate::auth::AuthMethod::BearerToken,
3507            name: "dev".into(),
3508            role: "restricted-exec".into(),
3509            raw_token: None,
3510            sub: None,
3511        };
3512        let app = rbac_router_with_identity(policy, id);
3513        let body = serde_json::json!([
3514            {
3515                "jsonrpc": "2.0",
3516                "id": 1,
3517                "method": "tools/call",
3518                "params": {
3519                    "name": "resource_exec",
3520                    "arguments": { "cmd": "ls -la", "host": "dev-1" }
3521                }
3522            },
3523            {
3524                "jsonrpc": "2.0",
3525                "id": 2,
3526                "method": "tools/call",
3527                "params": {
3528                    "name": "resource_exec",
3529                    "arguments": { "cmd": "rm -rf /", "host": "dev-1" }
3530                }
3531            }
3532        ])
3533        .to_string();
3534        let req = Request::builder()
3535            .method(Method::POST)
3536            .uri("/mcp")
3537            .header("content-type", "application/json")
3538            .body(Body::from(body))
3539            .unwrap();
3540        let resp = app.oneshot(req).await.unwrap();
3541        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3542    }
3543
3544    // -- redact_arg / redaction_salt tests --
3545
3546    #[test]
3547    fn redact_with_salt_is_deterministic_per_salt() {
3548        let salt = b"unit-test-salt";
3549        let a = redact_with_salt(salt, "rm -rf /");
3550        let b = redact_with_salt(salt, "rm -rf /");
3551        assert_eq!(a, b, "same input + salt must yield identical hash");
3552        assert_eq!(a.len(), 8, "redacted hash is 8 hex chars (4 bytes)");
3553        assert!(
3554            a.chars().all(|c| c.is_ascii_hexdigit()),
3555            "redacted hash must be lowercase hex: {a}"
3556        );
3557    }
3558
3559    #[test]
3560    fn redact_with_salt_differs_across_salts() {
3561        let v = "the-same-value";
3562        let h1 = redact_with_salt(b"salt-one", v);
3563        let h2 = redact_with_salt(b"salt-two", v);
3564        assert_ne!(
3565            h1, h2,
3566            "different salts must produce different hashes for the same value"
3567        );
3568    }
3569
3570    #[test]
3571    fn redact_with_salt_distinguishes_values() {
3572        let salt = b"k";
3573        let h1 = redact_with_salt(salt, "alpha");
3574        let h2 = redact_with_salt(salt, "beta");
3575        // Hash collisions on 32 bits are 1-in-4-billion; safe to assert.
3576        assert_ne!(h1, h2, "different values must produce different hashes");
3577    }
3578
3579    #[test]
3580    fn policy_with_configured_salt_redacts_consistently() {
3581        let cfg = RbacConfig {
3582            enabled: true,
3583            roles: vec![],
3584            redaction_salt: Some(SecretString::from("my-stable-salt")),
3585            ..RbacConfig::default()
3586        };
3587        let p1 = RbacPolicy::new(&cfg);
3588        let p2 = RbacPolicy::new(&cfg);
3589        assert_eq!(
3590            p1.redact_arg("payload"),
3591            p2.redact_arg("payload"),
3592            "policies built from the same configured salt must agree"
3593        );
3594    }
3595
3596    #[test]
3597    fn policy_without_configured_salt_uses_process_salt() {
3598        let cfg = RbacConfig {
3599            enabled: true,
3600            roles: vec![],
3601            redaction_salt: None,
3602            ..RbacConfig::default()
3603        };
3604        let p1 = RbacPolicy::new(&cfg);
3605        let p2 = RbacPolicy::new(&cfg);
3606        // Within one process, the lazy OnceLock salt is shared.
3607        assert_eq!(
3608            p1.redact_arg("payload"),
3609            p2.redact_arg("payload"),
3610            "process-wide salt must be consistent within one process"
3611        );
3612    }
3613
3614    // -- enforce_tool_policy identity propagation regression test (BUG H-S3) --
3615
3616    /// Regression: when `enforce_tool_policy` denied a request, the deny
3617    /// log used to read `current_identity()`, which was always `None` at
3618    /// that point because the task-local context is installed *after*
3619    /// policy enforcement. The fix passes `identity_name` explicitly.
3620    ///
3621    /// We assert the deny path returns 403 (the visible behaviour).
3622    /// The log-content assertion lives behind tracing-test which we have
3623    /// not yet added as a dev-dep; the explicit-parameter signature alone
3624    /// makes the previous bug structurally impossible.
3625    #[tokio::test]
3626    async fn deny_path_uses_explicit_identity_not_task_local() {
3627        let policy = Arc::new(test_policy());
3628        let id = AuthIdentity {
3629            method: crate::auth::AuthMethod::BearerToken,
3630            name: "alice-the-auditor".into(),
3631            role: "viewer".into(),
3632            raw_token: None,
3633            sub: None,
3634        };
3635        let app = rbac_router_with_identity(policy, id);
3636        // viewer is not allowed to call resource_delete -> 403.
3637        let body = tool_call_body("resource_delete", &serde_json::json!({}));
3638        let req = Request::builder()
3639            .method(Method::POST)
3640            .uri("/mcp")
3641            .header("content-type", "application/json")
3642            .body(Body::from(body))
3643            .unwrap();
3644        let resp = app.oneshot(req).await.unwrap();
3645        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3646    }
3647
3648    // -- M2 regression: non-string argument values bypass allowlist --
3649
3650    fn restricted_exec_identity() -> AuthIdentity {
3651        AuthIdentity {
3652            method: crate::auth::AuthMethod::BearerToken,
3653            name: "carol".into(),
3654            role: "restricted-exec".into(),
3655            raw_token: None,
3656            sub: None,
3657        }
3658    }
3659
3660    #[test]
3661    fn has_argument_allowlist_matches_configured_tool_argument() {
3662        let policy = test_policy();
3663        assert!(policy.has_argument_allowlist("restricted-exec", "resource_exec", "cmd"));
3664        assert!(!policy.has_argument_allowlist("restricted-exec", "resource_exec", "host"));
3665        assert!(!policy.has_argument_allowlist("restricted-exec", "other_tool", "cmd"));
3666        assert!(!policy.has_argument_allowlist("ops", "resource_exec", "cmd"));
3667    }
3668
3669    #[tokio::test]
3670    async fn array_arg_with_matching_allowlist_is_denied() {
3671        let policy = Arc::new(test_policy());
3672        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3673        let body = tool_call_body(
3674            "resource_exec",
3675            &serde_json::json!({ "host": "dev-1", "cmd": ["bash", "-c", "evil"] }),
3676        );
3677        let req = Request::builder()
3678            .method(Method::POST)
3679            .uri("/mcp")
3680            .header("content-type", "application/json")
3681            .body(Body::from(body))
3682            .unwrap();
3683        let resp = app.oneshot(req).await.unwrap();
3684        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3685    }
3686
3687    #[tokio::test]
3688    async fn object_arg_with_matching_allowlist_is_denied() {
3689        let policy = Arc::new(test_policy());
3690        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3691        let body = tool_call_body(
3692            "resource_exec",
3693            &serde_json::json!({ "host": "dev-1", "cmd": { "raw": "sh" } }),
3694        );
3695        let req = Request::builder()
3696            .method(Method::POST)
3697            .uri("/mcp")
3698            .header("content-type", "application/json")
3699            .body(Body::from(body))
3700            .unwrap();
3701        let resp = app.oneshot(req).await.unwrap();
3702        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3703    }
3704
3705    #[tokio::test]
3706    async fn number_arg_with_matching_allowlist_is_denied() {
3707        let policy = Arc::new(test_policy());
3708        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3709        let body = tool_call_body(
3710            "resource_exec",
3711            &serde_json::json!({ "host": "dev-1", "cmd": 42 }),
3712        );
3713        let req = Request::builder()
3714            .method(Method::POST)
3715            .uri("/mcp")
3716            .header("content-type", "application/json")
3717            .body(Body::from(body))
3718            .unwrap();
3719        let resp = app.oneshot(req).await.unwrap();
3720        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3721    }
3722
3723    #[tokio::test]
3724    async fn bool_arg_with_matching_allowlist_is_denied() {
3725        let policy = Arc::new(test_policy());
3726        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3727        let body = tool_call_body(
3728            "resource_exec",
3729            &serde_json::json!({ "host": "dev-1", "cmd": true }),
3730        );
3731        let req = Request::builder()
3732            .method(Method::POST)
3733            .uri("/mcp")
3734            .header("content-type", "application/json")
3735            .body(Body::from(body))
3736            .unwrap();
3737        let resp = app.oneshot(req).await.unwrap();
3738        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3739    }
3740
3741    #[tokio::test]
3742    async fn null_arg_with_matching_allowlist_is_denied() {
3743        let policy = Arc::new(test_policy());
3744        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3745        let body = tool_call_body(
3746            "resource_exec",
3747            &serde_json::json!({ "host": "dev-1", "cmd": null }),
3748        );
3749        let req = Request::builder()
3750            .method(Method::POST)
3751            .uri("/mcp")
3752            .header("content-type", "application/json")
3753            .body(Body::from(body))
3754            .unwrap();
3755        let resp = app.oneshot(req).await.unwrap();
3756        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
3757    }
3758
3759    #[tokio::test]
3760    async fn non_string_arg_without_allowlist_is_passthrough() {
3761        // ops has no argument_allowlist for any (tool, arg) tuple, so
3762        // non-string values must reach the handler. resource_exec is in
3763        // ops's allow list so the call should not be rejected by RBAC.
3764        let policy = Arc::new(test_policy());
3765        let id = AuthIdentity {
3766            method: crate::auth::AuthMethod::BearerToken,
3767            name: "olivia".into(),
3768            role: "ops".into(),
3769            raw_token: None,
3770            sub: None,
3771        };
3772        let app = rbac_router_with_identity(policy, id);
3773        let body = tool_call_body(
3774            "resource_exec",
3775            &serde_json::json!({ "host": "dev-1", "cmd": ["bash"] }),
3776        );
3777        let req = Request::builder()
3778            .method(Method::POST)
3779            .uri("/mcp")
3780            .header("content-type", "application/json")
3781            .body(Body::from(body))
3782            .unwrap();
3783        let resp = app.oneshot(req).await.unwrap();
3784        assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3785    }
3786
3787    #[tokio::test]
3788    async fn string_arg_in_allowlist_still_passes() {
3789        let policy = Arc::new(test_policy());
3790        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3791        let body = tool_call_body(
3792            "resource_exec",
3793            &serde_json::json!({ "host": "dev-1", "cmd": "bash" }),
3794        );
3795        let req = Request::builder()
3796            .method(Method::POST)
3797            .uri("/mcp")
3798            .header("content-type", "application/json")
3799            .body(Body::from(body))
3800            .unwrap();
3801        let resp = app.oneshot(req).await.unwrap();
3802        assert_ne!(resp.status(), StatusCode::FORBIDDEN);
3803    }
3804
3805    // -- F4 regression: non-string `host` downgraded the host-glob check --
3806    //
3807    // `restricted-exec` is scoped to `hosts: ["dev-*"]`. Before the fix,
3808    // `arguments.host` was read with `as_str()`, so any non-string shape
3809    // yielded `None` and routed to `check_operation`, skipping the host
3810    // globs entirely -- letting a caller reach `prod-1` by sending the
3811    // host as an array. Each case below returned 200 before the fix.
3812
3813    async fn exec_status(args: &serde_json::Value) -> StatusCode {
3814        let policy = Arc::new(test_policy());
3815        let app = rbac_router_with_identity(policy, restricted_exec_identity());
3816        let body = tool_call_body("resource_exec", args);
3817        let req = Request::builder()
3818            .method(Method::POST)
3819            .uri("/mcp")
3820            .header("content-type", "application/json")
3821            .body(Body::from(body))
3822            .unwrap();
3823        app.oneshot(req).await.unwrap().status()
3824    }
3825
3826    #[tokio::test]
3827    async fn non_string_host_is_denied_for_every_json_type() {
3828        for host in [
3829            serde_json::json!(["prod-1"]),
3830            serde_json::json!({ "name": "prod-1" }),
3831            serde_json::json!(42),
3832            serde_json::json!(true),
3833            serde_json::json!(null),
3834        ] {
3835            let args = serde_json::json!({ "host": host, "cmd": "sh" });
3836            assert_eq!(
3837                exec_status(&args).await,
3838                StatusCode::FORBIDDEN,
3839                "non-string host must not bypass host globs: {host:?}"
3840            );
3841        }
3842    }
3843
3844    #[tokio::test]
3845    async fn string_host_outside_globs_still_denied() {
3846        let args = serde_json::json!({ "host": "prod-1", "cmd": "sh" });
3847        assert_eq!(exec_status(&args).await, StatusCode::FORBIDDEN);
3848    }
3849
3850    #[tokio::test]
3851    async fn string_host_inside_globs_still_allowed() {
3852        let args = serde_json::json!({ "host": "dev-1", "cmd": "sh" });
3853        assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3854    }
3855
3856    /// Asserts the deliberate scope boundary: an absent `host` still routes
3857    /// to `check_operation` so hostless tools keep working. Requiring a host
3858    /// unconditionally would break `ping` / `list_hosts`.
3859    #[tokio::test]
3860    async fn absent_host_still_routes_to_check_operation() {
3861        let args = serde_json::json!({ "cmd": "sh" });
3862        assert_ne!(exec_status(&args).await, StatusCode::FORBIDDEN);
3863    }
3864
3865    // -- F5: opt-in `required` on ArgumentAllowlist --
3866    //
3867    // An allowlist constrains a value only when the argument is present, so a
3868    // caller could skip it entirely by omitting the key. That is safe when the
3869    // tool's input schema marks the argument required, but fails open when the
3870    // handler substitutes a default. `required` is opt-in so existing configs
3871    // are untouched.
3872
3873    fn required_policy(allowed: Vec<String>, required: bool) -> RbacPolicy {
3874        let role = RoleConfig::new("viewer", vec!["run".into()], vec!["*".into()])
3875            .with_argument_allowlists(vec![
3876                ArgumentAllowlist::new("run", "cmd", allowed).with_required(required),
3877            ]);
3878        let mut config = RbacConfig::with_roles(vec![role]);
3879        config.enabled = true;
3880        RbacPolicy::new(&config)
3881    }
3882
3883    fn viewer_identity() -> AuthIdentity {
3884        AuthIdentity {
3885            method: crate::auth::AuthMethod::BearerToken,
3886            name: "viewer-1".into(),
3887            role: "viewer".into(),
3888            raw_token: None,
3889            sub: None,
3890        }
3891    }
3892
3893    async fn run_status(policy: RbacPolicy, params: &serde_json::Value) -> StatusCode {
3894        let app = rbac_router_with_identity(Arc::new(policy), viewer_identity());
3895        let body = serde_json::json!({
3896            "jsonrpc": "2.0",
3897            "id": 1,
3898            "method": "tools/call",
3899            "params": params
3900        })
3901        .to_string();
3902        let req = Request::builder()
3903            .method(Method::POST)
3904            .uri("/mcp")
3905            .header("content-type", "application/json")
3906            .body(Body::from(body))
3907            .unwrap();
3908        app.oneshot(req).await.unwrap().status()
3909    }
3910
3911    #[tokio::test]
3912    async fn required_false_still_allows_omitting_the_argument() {
3913        let params = serde_json::json!({ "name": "run", "arguments": {} });
3914        assert_ne!(
3915            run_status(required_policy(vec!["ls".into()], false), &params).await,
3916            StatusCode::FORBIDDEN,
3917            "default behaviour must be unchanged"
3918        );
3919    }
3920
3921    #[tokio::test]
3922    async fn required_true_denies_omitted_argument() {
3923        let params = serde_json::json!({ "name": "run", "arguments": {} });
3924        assert_eq!(
3925            run_status(required_policy(vec!["ls".into()], true), &params).await,
3926            StatusCode::FORBIDDEN
3927        );
3928    }
3929
3930    #[tokio::test]
3931    async fn required_true_allows_permitted_value() {
3932        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "ls -la" } });
3933        assert_ne!(
3934            run_status(required_policy(vec!["ls".into()], true), &params).await,
3935            StatusCode::FORBIDDEN
3936        );
3937    }
3938
3939    #[tokio::test]
3940    async fn required_true_still_denies_disallowed_value() {
3941        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": "rm -rf /" } });
3942        assert_eq!(
3943            run_status(required_policy(vec!["ls".into()], true), &params).await,
3944            StatusCode::FORBIDDEN
3945        );
3946    }
3947
3948    #[tokio::test]
3949    async fn required_true_denies_non_string_value() {
3950        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": ["ls"] } });
3951        assert_eq!(
3952            run_status(required_policy(vec!["ls".into()], true), &params).await,
3953            StatusCode::FORBIDDEN
3954        );
3955    }
3956
3957    #[tokio::test]
3958    async fn required_true_denies_absent_or_non_object_arguments() {
3959        for params in [
3960            serde_json::json!({ "name": "run" }),
3961            serde_json::json!({ "name": "run", "arguments": "not-an-object" }),
3962            serde_json::json!({ "name": "run", "arguments": null }),
3963        ] {
3964            assert_eq!(
3965                run_status(required_policy(vec!["ls".into()], true), &params).await,
3966                StatusCode::FORBIDDEN,
3967                "omitting the arguments object must not skip `required`: {params:?}"
3968            );
3969        }
3970    }
3971
3972    // Empty `allowed` means "unrestricted value". Combined with `required`
3973    // that is "must be supplied as a string, any value accepted".
3974    #[tokio::test]
3975    async fn required_true_with_empty_allowed_accepts_any_string() {
3976        let params =
3977            serde_json::json!({ "name": "run", "arguments": { "cmd": "anything at all" } });
3978        assert_ne!(
3979            run_status(required_policy(vec![], true), &params).await,
3980            StatusCode::FORBIDDEN
3981        );
3982    }
3983
3984    #[tokio::test]
3985    async fn required_true_with_empty_allowed_denies_omitted_argument() {
3986        let params = serde_json::json!({ "name": "run", "arguments": {} });
3987        assert_eq!(
3988            run_status(required_policy(vec![], true), &params).await,
3989            StatusCode::FORBIDDEN
3990        );
3991    }
3992
3993    #[tokio::test]
3994    async fn required_true_with_empty_allowed_denies_non_string() {
3995        let params = serde_json::json!({ "name": "run", "arguments": { "cmd": 42 } });
3996        assert_eq!(
3997            run_status(required_policy(vec![], true), &params).await,
3998            StatusCode::FORBIDDEN
3999        );
4000    }
4001
4002    #[tokio::test]
4003    async fn required_honours_globbed_tool_patterns() {
4004        let role = RoleConfig::new("viewer", vec!["*".into()], vec!["*".into()])
4005            .with_argument_allowlists(vec![
4006                ArgumentAllowlist::new("run-*", "cmd", vec!["ls".into()]).with_required(true),
4007            ]);
4008        let mut config = RbacConfig::with_roles(vec![role]);
4009        config.enabled = true;
4010        let params = serde_json::json!({ "name": "run-foo", "arguments": {} });
4011        assert_eq!(
4012            run_status(RbacPolicy::new(&config), &params).await,
4013            StatusCode::FORBIDDEN,
4014            "a globbed tool pattern must enforce presence, not just value"
4015        );
4016    }
4017
4018    #[test]
4019    fn required_defaults_to_false_when_absent_from_toml() {
4020        let cfg: RbacConfig = toml::from_str(
4021            r#"
4022            enabled = true
4023            [[roles]]
4024            name = "viewer"
4025            allow = ["run"]
4026            [[roles.argument_allowlists]]
4027            tool = "run"
4028            argument = "cmd"
4029            allowed = ["ls"]
4030            "#,
4031        )
4032        .expect("config without `required` must still deserialize");
4033        assert!(
4034            !cfg.roles[0].argument_allowlists[0].required,
4035            "omitted `required` must default to false so existing configs are unchanged"
4036        );
4037    }
4038
4039    #[test]
4040    fn unknown_rbac_config_key_is_rejected() {
4041        let err = toml::from_str::<RbacConfig>(
4042            "
4043            enabled = true
4044            typo_roles = []
4045            ",
4046        )
4047        .unwrap_err();
4048
4049        let msg = err.to_string();
4050        assert!(
4051            msg.contains("typo_roles"),
4052            "error must name the offending key: {msg}"
4053        );
4054    }
4055}