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