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