Skip to main content

macp_auth/
security.rs

1use macp_core::error::MacpError;
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::fs;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::sync::Mutex;
8use tonic::metadata::MetadataMap;
9
10#[derive(Clone, Debug)]
11pub struct AuthIdentity {
12    pub sender: String,
13    pub allowed_modes: Option<HashSet<String>>,
14    pub can_start_sessions: bool,
15    pub max_open_sessions: Option<usize>,
16    pub can_manage_mode_registry: bool,
17    pub is_observer: bool,
18}
19
20#[derive(Clone, Debug, serde::Deserialize)]
21struct RawIdentity {
22    token: String,
23    sender: String,
24    #[serde(default)]
25    allowed_modes: Vec<String>,
26    #[serde(default = "default_true")]
27    can_start_sessions: bool,
28    max_open_sessions: Option<usize>,
29    #[serde(default)]
30    can_manage_mode_registry: bool,
31    #[serde(default)]
32    is_observer: bool,
33}
34
35#[derive(Clone, Debug, serde::Deserialize)]
36#[serde(untagged)]
37enum RawConfig {
38    List(Vec<RawIdentity>),
39    Wrapped { tokens: Vec<RawIdentity> },
40}
41
42fn default_true() -> bool {
43    true
44}
45
46#[derive(Clone, Debug)]
47pub struct RateLimitConfig {
48    pub limit: usize,
49    pub window: Duration,
50}
51
52/// Effective `ListSessions` page size when the client sends `page_size = 0`.
53/// Overridable with `MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE`.
54pub const DEFAULT_LIST_SESSIONS_PAGE_SIZE: usize = 100;
55
56/// Hard cap on `ListSessions` page size; a larger client-requested `page_size`
57/// is clamped down to it. Overridable with `MACP_LIST_SESSIONS_MAX_PAGE_SIZE`.
58pub const MAX_LIST_SESSIONS_PAGE_SIZE: usize = 1000;
59
60/// Raw (unparsed) values of the two `ListSessions` page-size env vars.
61///
62/// A named struct rather than two positional `Option<String>` parameters
63/// precisely because the two arguments are the same type: with positional
64/// arguments, transposing them at the call site compiles, and the resulting
65/// misconfiguration (`MAX` feeding the default and vice versa) is invisible to
66/// every test that only drives the resolver. Naming the fields forces the call
67/// site to write the field name next to the env-var name it reads.
68struct RawPageSizeEnv {
69    default_raw: Option<String>,
70    max_raw: Option<String>,
71}
72
73#[derive(Default)]
74struct RateBucket {
75    start_events: Mutex<HashMap<String, VecDeque<Instant>>>,
76    message_events: Mutex<HashMap<String, VecDeque<Instant>>>,
77    /// Requests since the last full stale-sweep of each map. Full sweeps are
78    /// amortized (every `SWEEP_EVERY` requests) so no single request pays a
79    /// scan proportional to total sender cardinality, while the maps still
80    /// get fully cleaned on a bounded cadence.
81    start_sweep_counter: std::sync::atomic::AtomicU64,
82    message_sweep_counter: std::sync::atomic::AtomicU64,
83}
84
85#[derive(Clone)]
86pub struct SecurityLayer {
87    identities: Arc<HashMap<String, AuthIdentity>>,
88    rate_bucket: Arc<RateBucket>,
89    auth_chain: Option<Arc<crate::auth::AuthResolverChain>>,
90    pub max_payload_bytes: usize,
91    /// Effective `ListSessions` page size when the client sends `page_size = 0`.
92    pub list_sessions_default_page_size: usize,
93    /// Hard cap applied to a client-requested `ListSessions` page size.
94    pub list_sessions_max_page_size: usize,
95    session_start_rate: RateLimitConfig,
96    message_rate: RateLimitConfig,
97}
98
99impl SecurityLayer {
100    /// Creates a test-friendly SecurityLayer that maps any bearer token
101    /// `"tok-<sender>"` to an identity with `sender = <token-value>`.
102    /// For tests, use `Authorization: Bearer agent://name` to authenticate as `agent://name`.
103    pub fn dev_mode() -> Self {
104        Self {
105            identities: Arc::new(HashMap::new()),
106            rate_bucket: Arc::new(RateBucket::default()),
107            auth_chain: None,
108            max_payload_bytes: 1_048_576,
109            // Deliberately the PRODUCTION defaults, not `usize::MAX`. The two
110            // rate limits below are unlimited because that is the safe test
111            // default, but an unlimited page size would make "the default cap
112            // was applied" assertions pass vacuously in unit tests while the
113            // same code path capped requests over the wire.
114            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
115            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
116            session_start_rate: RateLimitConfig {
117                limit: usize::MAX,
118                window: Duration::from_secs(60),
119            },
120            message_rate: RateLimitConfig {
121                limit: usize::MAX,
122                window: Duration::from_secs(60),
123            },
124        }
125    }
126
127    /// Dev-mode authenticate: accepts any bearer token as a FULLY-PRIVILEGED
128    /// identity (can start sessions, can manage the mode registry).
129    ///
130    /// Reached whenever no auth is configured — both by `dev_mode()` in tests
131    /// AND by `from_env()` when the operator sets no tokens/issuer. Startup
132    /// therefore refuses to run without configured auth unless
133    /// `MACP_ALLOW_INSECURE=1` (see `has_configured_auth` and `src/main.rs`);
134    /// an operator who forgets auth env vars must not silently run an
135    /// any-token-is-admin server.
136    fn dev_authenticate(&self, metadata: &MetadataMap) -> Result<AuthIdentity, MacpError> {
137        if let Some(token) = Self::bearer_token(metadata) {
138            return Ok(AuthIdentity {
139                sender: token,
140                allowed_modes: None,
141                can_start_sessions: true,
142                max_open_sessions: None,
143                can_manage_mode_registry: true,
144                is_observer: false,
145            });
146        }
147        Err(MacpError::Unauthenticated)
148    }
149
150    /// Resolves the two `ListSessions` page-size limits from raw env-var
151    /// values. Split out of `from_env` so the parse/clamp behavior is unit
152    /// testable without mutating the process environment, which races with
153    /// concurrently running tests under cargo's test thread pool.
154    ///
155    /// Like the other numeric vars this layer reads, it is silent on bad
156    /// input: an unparseable value — and `0`, which is just as much an
157    /// operator error — falls back to the compiled-in default rather than
158    /// producing a degenerate page size.
159    ///
160    /// Being silent here is only safe for the `macp-runtime` **binary**, which
161    /// runs `validate_env_config` in `src/main.rs` first and refuses to start
162    /// on either kind of bad input. A library embedder calling
163    /// [`SecurityLayer::from_env`] directly gets no such validation, so this
164    /// fallback — not a startup abort — is what it actually sees.
165    fn resolve_list_sessions_page_sizes(raw: RawPageSizeEnv) -> (usize, usize) {
166        let RawPageSizeEnv {
167            default_raw,
168            max_raw,
169        } = raw;
170        // `filter(|&v| v > 0)` rather than a trailing `.max(1)`: `0` is the
171        // same class of operator error as `"abc"` and gets the same treatment
172        // (fall back to the compiled-in value) instead of silently becoming a
173        // one-item page. The limits are still never zero.
174        let max = max_raw
175            .and_then(|v| v.parse::<usize>().ok())
176            .filter(|&v| v > 0)
177            .unwrap_or(MAX_LIST_SESSIONS_PAGE_SIZE);
178        let default_from_env = default_raw
179            .and_then(|v| v.parse::<usize>().ok())
180            .filter(|&v| v > 0);
181        // Provenance, not just the value: the clamp warning must not name
182        // `MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE` when nothing set it. Note this
183        // tracks where the value in hand actually came from, so an unparseable
184        // or zero default counts as built-in — that operator is told about the
185        // max they *did* set, and `validate_env_config` reports the bad default
186        // separately.
187        let mut default = default_from_env.unwrap_or(DEFAULT_LIST_SESSIONS_PAGE_SIZE);
188        if default > max {
189            tracing::warn!(
190                effective_default = default,
191                default_source = Self::page_size_default_source(default_from_env.is_some()),
192                max,
193                "{}",
194                Self::clamp_warning_message(default_from_env.is_some())
195            );
196            default = max;
197        }
198        (default, max)
199    }
200
201    /// Where the effective `ListSessions` default page size came from, as a
202    /// log field.
203    fn page_size_default_source(default_from_env: bool) -> &'static str {
204        if default_from_env {
205            "MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE"
206        } else {
207            "built-in"
208        }
209    }
210
211    /// Wording for the clamp warning emitted by
212    /// [`SecurityLayer::resolve_list_sessions_page_sizes`].
213    ///
214    /// Split out purely so the *choice* between the two messages is unit
215    /// testable: this crate has no test subscriber, so the emitted line itself
216    /// cannot be asserted without a new dev-dependency.
217    ///
218    /// The built-in wording exists because the reachable-for-the-binary case
219    /// (see `from_env_clamps_default_page_size_to_max`) is the one where the
220    /// operator set only the max. Naming a variable they never configured
221    /// sends them grepping for it.
222    fn clamp_warning_message(default_from_env: bool) -> &'static str {
223        if default_from_env {
224            "MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE exceeds MACP_LIST_SESSIONS_MAX_PAGE_SIZE; clamping the default down to the max"
225        } else {
226            "the effective ListSessions default page size is the built-in one and exceeds MACP_LIST_SESSIONS_MAX_PAGE_SIZE; clamping the default down to the max"
227        }
228    }
229
230    pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
231        let max_payload_bytes = std::env::var("MACP_MAX_PAYLOAD_BYTES")
232            .ok()
233            .and_then(|v| v.parse::<usize>().ok())
234            .unwrap_or(1_048_576);
235
236        // The field names below are the only thing tying each env var to the
237        // parameter it feeds; keep them next to their `std::env::var` call.
238        // Unit tests here can only exercise the resolver, so they cannot catch
239        // a transposition at this call site — the end-to-end coverage that
240        // pins the binding lives with the `ListSessions` handler, in
241        // `integration_tests/tests/tier1_protocol/test_list_sessions_pagination.rs`
242        // (`default_page_size_applied_when_page_size_is_zero` and
243        // `page_size_above_max_is_clamped` set the two vars to distinct values
244        // and assert the row counts each one produces).
245        let (list_sessions_default_page_size, list_sessions_max_page_size) =
246            Self::resolve_list_sessions_page_sizes(RawPageSizeEnv {
247                default_raw: std::env::var("MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE").ok(),
248                max_raw: std::env::var("MACP_LIST_SESSIONS_MAX_PAGE_SIZE").ok(),
249            });
250
251        let session_start_rate = RateLimitConfig {
252            limit: std::env::var("MACP_SESSION_START_LIMIT_PER_MINUTE")
253                .ok()
254                .and_then(|v| v.parse::<usize>().ok())
255                .unwrap_or(60),
256            window: Duration::from_secs(60),
257        };
258        let message_rate = RateLimitConfig {
259            limit: std::env::var("MACP_MESSAGE_LIMIT_PER_MINUTE")
260                .ok()
261                .and_then(|v| v.parse::<usize>().ok())
262                .unwrap_or(600),
263            window: Duration::from_secs(60),
264        };
265
266        let raw = if let Ok(json) = std::env::var("MACP_AUTH_TOKENS_JSON") {
267            Some(json)
268        } else if let Ok(path) = std::env::var("MACP_AUTH_TOKENS_FILE") {
269            Some(fs::read_to_string(PathBuf::from(path))?)
270        } else {
271            None
272        };
273
274        let identities = raw
275            .as_ref()
276            .map(|json| Self::parse_identities(json))
277            .transpose()?
278            .unwrap_or_default();
279
280        // Build auth resolver chain
281        let mut resolvers: Vec<Box<dyn crate::auth::AuthResolver>> = Vec::new();
282
283        // JWT resolver (if configured)
284        if let Ok(issuer) = std::env::var("MACP_AUTH_ISSUER") {
285            let audience =
286                std::env::var("MACP_AUTH_AUDIENCE").unwrap_or_else(|_| "macp-runtime".into());
287            let cache_ttl = std::env::var("MACP_AUTH_JWKS_TTL_SECS")
288                .ok()
289                .and_then(|v| v.parse().ok())
290                .unwrap_or(300u64);
291            // Asymmetric algorithms only by default. HS256 in a default
292            // allowlist is a latent confusion risk: if the JWKS ever contains
293            // an `oct` key, symmetric tokens become verifiable. Operators who
294            // genuinely use shared-secret JWTs must opt in explicitly.
295            let algorithms = std::env::var("MACP_AUTH_JWT_ALGS")
296                .ok()
297                .map(|raw| {
298                    raw.split(',')
299                        .filter_map(|a| match a.trim().to_uppercase().as_str() {
300                            "RS256" => Some(jsonwebtoken::Algorithm::RS256),
301                            "ES256" => Some(jsonwebtoken::Algorithm::ES256),
302                            "HS256" => Some(jsonwebtoken::Algorithm::HS256),
303                            other => {
304                                tracing::warn!(alg = other, "ignoring unsupported JWT algorithm");
305                                None
306                            }
307                        })
308                        .collect::<Vec<_>>()
309                })
310                .filter(|algs| !algs.is_empty())
311                .unwrap_or_else(|| {
312                    vec![
313                        jsonwebtoken::Algorithm::RS256,
314                        jsonwebtoken::Algorithm::ES256,
315                    ]
316                });
317            let config = crate::auth::resolvers::jwt_bearer::JwtConfig {
318                issuer,
319                audience,
320                algorithms,
321            };
322            if let Ok(jwks_json) = std::env::var("MACP_AUTH_JWKS_JSON") {
323                match crate::auth::resolvers::JwtBearerResolver::from_inline_json(
324                    config, &jwks_json,
325                ) {
326                    Ok(resolver) => resolvers.push(Box::new(resolver)),
327                    Err(e) => {
328                        tracing::error!("failed to create JWT resolver from inline JWKS: {e}")
329                    }
330                }
331            } else if let Ok(jwks_url) = std::env::var("MACP_AUTH_JWKS_URL") {
332                resolvers.push(Box::new(
333                    crate::auth::resolvers::JwtBearerResolver::from_url(
334                        config, jwks_url, cache_ttl,
335                    ),
336                ));
337            }
338        }
339
340        // Static bearer resolver (always present if tokens are configured)
341        if !identities.is_empty() {
342            resolvers.push(Box::new(crate::auth::resolvers::StaticBearerResolver::new(
343                identities.clone(),
344            )));
345        }
346
347        let auth_chain = if resolvers.is_empty() {
348            None
349        } else {
350            Some(Arc::new(crate::auth::AuthResolverChain::new(resolvers)))
351        };
352
353        Ok(Self {
354            identities: Arc::new(identities),
355            rate_bucket: Arc::new(RateBucket::default()),
356            auth_chain,
357            max_payload_bytes,
358            list_sessions_default_page_size,
359            list_sessions_max_page_size,
360            session_start_rate,
361            message_rate,
362        })
363    }
364
365    fn parse_identities(
366        json: &str,
367    ) -> Result<HashMap<String, AuthIdentity>, Box<dyn std::error::Error>> {
368        let parsed: RawConfig = serde_json::from_str(json)?;
369        let items = match parsed {
370            RawConfig::List(items) => items,
371            RawConfig::Wrapped { tokens } => tokens,
372        };
373        let mut identities = HashMap::new();
374        for item in items {
375            identities.insert(
376                item.token,
377                AuthIdentity {
378                    sender: item.sender,
379                    allowed_modes: if item.allowed_modes.is_empty() {
380                        None
381                    } else {
382                        Some(item.allowed_modes.into_iter().collect())
383                    },
384                    can_start_sessions: item.can_start_sessions,
385                    max_open_sessions: item.max_open_sessions,
386                    can_manage_mode_registry: item.can_manage_mode_registry,
387                    is_observer: item.is_observer,
388                },
389            );
390        }
391        Ok(identities)
392    }
393
394    fn bearer_token(metadata: &MetadataMap) -> Option<String> {
395        metadata
396            .get("authorization")
397            .and_then(|value| value.to_str().ok())
398            .and_then(|value| value.strip_prefix("Bearer "))
399            .map(str::to_string)
400            .or_else(|| {
401                metadata
402                    .get("x-macp-token")
403                    .and_then(|value| value.to_str().ok())
404                    .map(str::to_string)
405            })
406    }
407
408    pub async fn authenticate_metadata(
409        &self,
410        metadata: &MetadataMap,
411    ) -> Result<AuthIdentity, MacpError> {
412        // Production path: use the auth resolver chain. Fully async — the
413        // previous block_in_place/block_on bridge parked a worker thread for
414        // the whole JWKS fetch and panicked on a current-thread runtime.
415        if let Some(chain) = &self.auth_chain {
416            return chain.authenticate(metadata).await;
417        }
418
419        // Explicit identity map (layer_with_tokens in tests)
420        if !self.identities.is_empty() {
421            if let Some(token) = Self::bearer_token(metadata) {
422                return self
423                    .identities
424                    .get(&token)
425                    .cloned()
426                    .ok_or(MacpError::Unauthenticated);
427            }
428            return Err(MacpError::Unauthenticated);
429        }
430
431        // Dev-mode: any bearer token → identity (for tests only)
432        self.dev_authenticate(metadata)
433    }
434
435    pub fn authorize_mode(
436        &self,
437        identity: &AuthIdentity,
438        mode: &str,
439        is_session_start: bool,
440    ) -> Result<(), MacpError> {
441        if is_session_start && !identity.can_start_sessions {
442            return Err(MacpError::Forbidden);
443        }
444        if let Some(allowed_modes) = &identity.allowed_modes {
445            if !allowed_modes.contains(mode) {
446                return Err(MacpError::Forbidden);
447            }
448        }
449        Ok(())
450    }
451
452    pub fn authorize_mode_registry(&self, identity: &AuthIdentity) -> Result<(), MacpError> {
453        if identity.can_manage_mode_registry {
454            Ok(())
455        } else {
456            Err(MacpError::Forbidden)
457        }
458    }
459
460    async fn check_bucket(
461        bucket: &Mutex<HashMap<String, VecDeque<Instant>>>,
462        sweep_counter: &std::sync::atomic::AtomicU64,
463        sender: &str,
464        config: &RateLimitConfig,
465    ) -> Result<(), MacpError> {
466        let now = Instant::now();
467        let mut guard = bucket.lock().await;
468
469        // Amortized stale-sender sweep. A per-request full scan is O(total
470        // senders) — and sender cardinality is attacker-controllable via
471        // distinct authenticated identities — so the full sweep runs only
472        // every SWEEP_EVERY requests. Between sweeps a request touches only
473        // its own deque. The map therefore stays bounded (a full clean every
474        // SWEEP_EVERY requests) without any request paying the whole scan
475        // more than 1/SWEEP_EVERY of the time.
476        const SWEEP_EVERY: u64 = 128;
477        let tick = sweep_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
478        if tick.is_multiple_of(SWEEP_EVERY) {
479            guard.retain(|_, deque| {
480                deque
481                    .back()
482                    .map(|last| now.duration_since(*last) <= config.window)
483                    .unwrap_or(false)
484            });
485        }
486
487        let deque = guard.entry(sender.to_string()).or_default();
488        while deque
489            .front()
490            .map(|instant| now.duration_since(*instant) > config.window)
491            .unwrap_or(false)
492        {
493            deque.pop_front();
494        }
495        if deque.len() >= config.limit {
496            return Err(MacpError::RateLimited);
497        }
498        deque.push_back(now);
499        Ok(())
500    }
501
502    /// Whether any real authentication is configured (static tokens and/or a
503    /// JWT resolver). When false, `authenticate_metadata` falls through to
504    /// the any-token-is-admin dev path — callers gate startup on this.
505    pub fn has_configured_auth(&self) -> bool {
506        self.auth_chain.is_some()
507    }
508
509    pub async fn enforce_rate_limit(
510        &self,
511        sender: &str,
512        is_session_start: bool,
513    ) -> Result<(), MacpError> {
514        if is_session_start {
515            Self::check_bucket(
516                &self.rate_bucket.start_events,
517                &self.rate_bucket.start_sweep_counter,
518                sender,
519                &self.session_start_rate,
520            )
521            .await
522        } else {
523            Self::check_bucket(
524                &self.rate_bucket.message_events,
525                &self.rate_bucket.message_sweep_counter,
526                sender,
527                &self.message_rate,
528            )
529            .await
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use std::io::Write;
538    use tempfile::NamedTempFile;
539    use tonic::metadata::MetadataMap;
540
541    /// Build a SecurityLayer with bearer token identities loaded from a JSON string.
542    /// This avoids touching environment variables (safe for parallel tests).
543    fn layer_with_tokens(json: &str) -> SecurityLayer {
544        let identities = SecurityLayer::parse_identities(json).expect("valid JSON");
545        SecurityLayer {
546            identities: Arc::new(identities),
547            rate_bucket: Arc::new(RateBucket::default()),
548            auth_chain: None,
549            max_payload_bytes: 1_048_576,
550            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
551            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
552            session_start_rate: RateLimitConfig {
553                limit: usize::MAX,
554                window: Duration::from_secs(60),
555            },
556            message_rate: RateLimitConfig {
557                limit: usize::MAX,
558                window: Duration::from_secs(60),
559            },
560        }
561    }
562
563    /// Build a SecurityLayer with no tokens that does not require auth.
564    fn insecure_layer() -> SecurityLayer {
565        SecurityLayer {
566            identities: Arc::new(HashMap::new()),
567            rate_bucket: Arc::new(RateBucket::default()),
568            auth_chain: None,
569            max_payload_bytes: 1_048_576,
570            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
571            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
572            session_start_rate: RateLimitConfig {
573                limit: usize::MAX,
574                window: Duration::from_secs(60),
575            },
576            message_rate: RateLimitConfig {
577                limit: usize::MAX,
578                window: Duration::from_secs(60),
579            },
580        }
581    }
582
583    // ---------------------------------------------------------------
584    // 1. dev_mode() creates a SecurityLayer that doesn't require auth
585    // ---------------------------------------------------------------
586
587    #[tokio::test]
588    async fn dev_mode_requires_dev_header() {
589        let layer = SecurityLayer::dev_mode();
590        let meta = MetadataMap::new();
591        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
592        assert!(matches!(err, MacpError::Unauthenticated));
593    }
594
595    #[tokio::test]
596    async fn dev_mode_rejects_dev_sender_header() {
597        let layer = SecurityLayer::dev_mode();
598        let mut meta = MetadataMap::new();
599        meta.insert("x-macp-agent-id", "agent://dev-bot".parse().unwrap());
600        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
601        assert!(matches!(err, MacpError::Unauthenticated));
602    }
603
604    #[test]
605    fn dev_mode_has_unlimited_rate_limits() {
606        let layer = SecurityLayer::dev_mode();
607        assert_eq!(layer.session_start_rate.limit, usize::MAX);
608        assert_eq!(layer.message_rate.limit, usize::MAX);
609    }
610
611    // ---------------------------------------------------------------
612    // 2. from_env() with no env vars creates an insecure layer
613    // ---------------------------------------------------------------
614
615    #[test]
616    fn from_env_defaults_without_env_vars() {
617        // Verify default configuration via direct construction.
618        let layer = insecure_layer();
619        assert_eq!(layer.max_payload_bytes, 1_048_576);
620    }
621
622    // ---------------------------------------------------------------
623    // 2b. ListSessions page-size limits (D5)
624    // ---------------------------------------------------------------
625    //
626    // These tests never call `std::env::set_var`. Cargo runs unit tests
627    // multi-threaded in a single process, so mutating the process environment
628    // would race with any concurrently running test that reads it. The
629    // existing precedent in this file (`from_env_defaults_without_env_vars`
630    // above) sidesteps that by asserting on a directly constructed layer
631    // rather than on `from_env`; the same discipline is applied here by
632    // driving the pure resolver `from_env` delegates to.
633
634    /// Test helper mirroring the shape `from_env` builds, so each test case
635    /// still names which raw value is which.
636    fn raw_page_sizes(default_raw: Option<&str>, max_raw: Option<&str>) -> RawPageSizeEnv {
637        RawPageSizeEnv {
638            default_raw: default_raw.map(str::to_owned),
639            max_raw: max_raw.map(str::to_owned),
640        }
641    }
642
643    #[test]
644    fn from_env_page_size_defaults_without_env_vars() {
645        // Neither variable present -> the production defaults.
646        assert_eq!(
647            SecurityLayer::resolve_list_sessions_page_sizes(RawPageSizeEnv {
648                default_raw: None,
649                max_raw: None,
650            }),
651            (DEFAULT_LIST_SESSIONS_PAGE_SIZE, MAX_LIST_SESSIONS_PAGE_SIZE)
652        );
653        assert_eq!(DEFAULT_LIST_SESSIONS_PAGE_SIZE, 100);
654        assert_eq!(MAX_LIST_SESSIONS_PAGE_SIZE, 1000);
655
656        // And `from_env` really does carry those values through — but only
657        // assert it when the ambient environment leaves the vars it reads
658        // unset, so a developer who exports them does not get a spurious
659        // failure. `from_env` is fallible on the auth vars too (it reads a
660        // token file / parses JSON / builds a JWT resolver), so those are part
661        // of the same guard.
662        if [
663            "MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE",
664            "MACP_LIST_SESSIONS_MAX_PAGE_SIZE",
665            "MACP_AUTH_TOKENS_FILE",
666            "MACP_AUTH_TOKENS_JSON",
667            "MACP_AUTH_ISSUER",
668        ]
669        .iter()
670        .all(|v| std::env::var_os(v).is_none())
671        {
672            let layer = SecurityLayer::from_env().expect("from_env with no auth configured");
673            assert_eq!(layer.list_sessions_default_page_size, 100);
674            assert_eq!(layer.list_sessions_max_page_size, 1000);
675        }
676    }
677
678    #[test]
679    fn dev_mode_uses_production_page_size_defaults() {
680        let layer = SecurityLayer::dev_mode();
681        assert_eq!(layer.list_sessions_default_page_size, 100);
682        assert_eq!(layer.list_sessions_max_page_size, 1000);
683        // Unlike the rate limits, these must NOT be unlimited. No existing cap
684        // assertion depends on these two values — `src/server.rs`'s unit tests
685        // pin their own via `page_size_security(..)`, and the Tier 1 coverage in
686        // `integration_tests/tests/tier1_protocol/test_list_sessions_pagination.rs`
687        // pins its own via env. The assertion is here because `dev_mode` is a
688        // `pub` constructor: anything that builds a server from it must still
689        // page for real, so a future test written against it cannot pass
690        // vacuously on an unbounded page.
691        assert_ne!(layer.list_sessions_default_page_size, usize::MAX);
692        assert_ne!(layer.list_sessions_max_page_size, usize::MAX);
693    }
694
695    #[test]
696    fn from_env_clamps_default_page_size_to_max() {
697        // Default above the (defaulted) max is clamped down; a `tracing::warn!`
698        // fires on the clamp (not asserted here — this crate has no test
699        // subscriber and adding one is not worth a dev-dependency).
700        //
701        // For the `macp-runtime` binary this branch is unreachable *when the
702        // default is explicitly set*: `validate_env_config` aborts startup on
703        // an explicit default above the effective max, whether or not the max
704        // itself was set. It is still reached by the binary when only the max
705        // is set, below the built-in default — `MACP_LIST_SESSIONS_MAX_PAGE_SIZE=50`
706        // alone passes validation (the cross-field check only fires on an
707        // explicit default) and then clamps 100 down to 50. That case is
708        // covered by `clamps_builtin_default_against_a_smaller_explicit_max`.
709        // The clamp also stays as defense in depth for library embedders that
710        // call `from_env` without any validation.
711        assert_eq!(
712            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(Some("5000"), None)),
713            (1000, 1000)
714        );
715        // Also clamped against an explicitly configured, smaller max.
716        assert_eq!(
717            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(
718                Some("2000"),
719                Some("50")
720            )),
721            (50, 50)
722        );
723        // A default at or below the max is left alone.
724        assert_eq!(
725            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(
726                Some("50"),
727                Some("200")
728            )),
729            (50, 200)
730        );
731        assert_eq!(
732            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(
733                Some("200"),
734                Some("200")
735            )),
736            (200, 200)
737        );
738    }
739
740    /// The one clamp case the `macp-runtime` binary can actually reach, and
741    /// the one every other clamp test misses: only the max is set, below the
742    /// built-in default. `validate_env_config`'s cross-field check fires only
743    /// on an explicitly set default, so `MACP_LIST_SESSIONS_MAX_PAGE_SIZE=50`
744    /// alone starts the server and lands here.
745    ///
746    /// Without this, making the clamp conditional on an explicitly supplied
747    /// default survives the whole suite while shipping `default=100 > max=50`.
748    #[test]
749    fn clamps_builtin_default_against_a_smaller_explicit_max() {
750        assert_eq!(
751            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(None, Some("50"))),
752            (50, 50),
753            "the built-in default must clamp to a smaller explicit max, not stay above it"
754        );
755
756        // The warning this path takes must not name a variable the operator
757        // never set. Only the message *choice* is asserted, not the emitted
758        // line: capturing `tracing` output would need a dev-dependency this
759        // crate does not carry.
760        let builtin = SecurityLayer::clamp_warning_message(false);
761        let explicit = SecurityLayer::clamp_warning_message(true);
762        assert!(
763            !builtin.contains("MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE"),
764            "the built-in-default warning must not name an unset variable: {builtin}"
765        );
766        assert!(
767            builtin.contains("built-in") && builtin.contains("MACP_LIST_SESSIONS_MAX_PAGE_SIZE"),
768            "the built-in-default warning must say where the default came from and name the max: {builtin}"
769        );
770        assert!(
771            explicit.contains("MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE"),
772            "an explicitly configured default must still be named: {explicit}"
773        );
774        assert_eq!(SecurityLayer::page_size_default_source(false), "built-in");
775        assert_eq!(
776            SecurityLayer::page_size_default_source(true),
777            "MACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE"
778        );
779    }
780
781    #[test]
782    fn page_size_resolver_treats_zero_like_garbage() {
783        // Unparseable values fall back to the defaults — this layer stays
784        // silent on bad input. For the binary, `validate_env_config` in
785        // `src/main.rs` refuses to start first; a library embedder calling
786        // `from_env` directly sees exactly the fallbacks asserted here.
787        assert_eq!(
788            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(Some("abc"), Some(""))),
789            (100, 1000)
790        );
791        assert_eq!(
792            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(
793                Some("-1"),
794                Some("1e3")
795            )),
796            (100, 1000)
797        );
798
799        // `0` is the same class of operator error as `"abc"` and gets exactly
800        // the same treatment: fall back to the compiled-in value. Notably it
801        // does NOT floor to 1 — a one-item page is a far more surprising
802        // outcome to attach to the more plausible typo.
803        assert_eq!(
804            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(Some("0"), None)),
805            (100, 1000),
806            "a default of 0 falls back to the compiled-in default, not to 1"
807        );
808        assert_eq!(
809            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(None, Some("0"))),
810            (100, 1000),
811            "a max of 0 falls back to the compiled-in max and leaves the default alone"
812        );
813        assert_eq!(
814            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(Some("0"), Some("0"))),
815            (100, 1000)
816        );
817        // Bad input on one side must not disturb a good value on the other.
818        assert_eq!(
819            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(Some("0"), Some("25"))),
820            (25, 25),
821            "the good max is honored, and the fallback default clamps to it"
822        );
823        assert_eq!(
824            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(Some("7"), Some("0"))),
825            (7, 1000),
826            "the good default is honored against the compiled-in max"
827        );
828
829        // Whatever the input, neither limit is ever degenerate.
830        for (default_raw, max_raw) in [
831            (Some("0"), None),
832            (None, Some("0")),
833            (Some("0"), Some("0")),
834            (Some("abc"), Some("0")),
835        ] {
836            let (default, max) = SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(
837                default_raw,
838                max_raw,
839            ));
840            assert!(
841                default > 0 && max > 0,
842                "{default_raw:?}/{max_raw:?} -> ({default}, {max})"
843            );
844        }
845
846        // A max larger than the default raises only the ceiling.
847        assert_eq!(
848            SecurityLayer::resolve_list_sessions_page_sizes(raw_page_sizes(None, Some("100000"))),
849            (100, 100_000)
850        );
851    }
852
853    // ---------------------------------------------------------------
854    // 3. Bearer token auth: loading tokens and authenticating
855    // ---------------------------------------------------------------
856
857    #[tokio::test]
858    async fn bearer_token_authentication_via_authorization_header() {
859        let json = r#"[{"token":"tok-abc","sender":"agent://alice","allowed_modes":[],"can_start_sessions":true}]"#;
860        let layer = layer_with_tokens(json);
861
862        let mut meta = MetadataMap::new();
863        meta.insert("authorization", "Bearer tok-abc".parse().unwrap());
864
865        let id = layer
866            .authenticate_metadata(&meta)
867            .await
868            .expect("should authenticate");
869        assert_eq!(id.sender, "agent://alice");
870        assert!(id.allowed_modes.is_none()); // empty vec -> None
871        assert!(id.can_start_sessions);
872    }
873
874    #[tokio::test]
875    async fn bearer_token_authentication_via_x_macp_token_header() {
876        let json = r#"[{"token":"tok-xyz","sender":"agent://bob"}]"#;
877        let layer = layer_with_tokens(json);
878
879        let mut meta = MetadataMap::new();
880        meta.insert("x-macp-token", "tok-xyz".parse().unwrap());
881
882        let id = layer
883            .authenticate_metadata(&meta)
884            .await
885            .expect("should authenticate");
886        assert_eq!(id.sender, "agent://bob");
887    }
888
889    #[tokio::test]
890    async fn invalid_bearer_token_returns_unauthenticated() {
891        let json = r#"[{"token":"tok-real","sender":"agent://alice"}]"#;
892        let layer = layer_with_tokens(json);
893
894        let mut meta = MetadataMap::new();
895        meta.insert("authorization", "Bearer tok-fake".parse().unwrap());
896
897        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
898        assert!(matches!(err, MacpError::Unauthenticated));
899    }
900
901    #[tokio::test]
902    async fn no_token_when_auth_required_returns_unauthenticated() {
903        let json = r#"[{"token":"tok-only","sender":"agent://sole"}]"#;
904        let layer = layer_with_tokens(json);
905
906        let meta = MetadataMap::new(); // no auth header at all
907        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
908        assert!(matches!(err, MacpError::Unauthenticated));
909    }
910
911    #[tokio::test]
912    async fn parse_identities_wrapped_format() {
913        let json = r#"{"tokens":[{"token":"t1","sender":"agent://wrapped"}]}"#;
914        let layer = layer_with_tokens(json);
915
916        let mut meta = MetadataMap::new();
917        meta.insert("authorization", "Bearer t1".parse().unwrap());
918        let id = layer
919            .authenticate_metadata(&meta)
920            .await
921            .expect("should authenticate");
922        assert_eq!(id.sender, "agent://wrapped");
923    }
924
925    #[tokio::test]
926    async fn parse_identities_with_allowed_modes() {
927        let json = r#"[{"token":"t-modes","sender":"agent://limited","allowed_modes":["macp.mode.decision.v1","macp.mode.task.v1"],"can_start_sessions":false,"max_open_sessions":5}]"#;
928        let layer = layer_with_tokens(json);
929
930        let mut meta = MetadataMap::new();
931        meta.insert("authorization", "Bearer t-modes".parse().unwrap());
932        let id = layer
933            .authenticate_metadata(&meta)
934            .await
935            .expect("should authenticate");
936
937        assert_eq!(id.sender, "agent://limited");
938        assert!(!id.can_start_sessions);
939        assert_eq!(id.max_open_sessions, Some(5));
940        let modes = id
941            .allowed_modes
942            .as_ref()
943            .expect("should have allowed_modes");
944        assert!(modes.contains("macp.mode.decision.v1"));
945        assert!(modes.contains("macp.mode.task.v1"));
946        assert!(!modes.contains("macp.mode.proposal.v1"));
947    }
948
949    #[tokio::test]
950    async fn authorization_header_takes_priority_over_x_macp_token() {
951        let json = r#"[
952            {"token":"bearer-tok","sender":"agent://bearer-user"},
953            {"token":"header-tok","sender":"agent://header-user"}
954        ]"#;
955        let layer = layer_with_tokens(json);
956
957        let mut meta = MetadataMap::new();
958        meta.insert("authorization", "Bearer bearer-tok".parse().unwrap());
959        meta.insert("x-macp-token", "header-tok".parse().unwrap());
960
961        let id = layer
962            .authenticate_metadata(&meta)
963            .await
964            .expect("should authenticate");
965        // Authorization header should take priority
966        assert_eq!(id.sender, "agent://bearer-user");
967    }
968
969    // ---------------------------------------------------------------
970    // 4. Dev header extraction: x-macp-agent-id
971    // ---------------------------------------------------------------
972
973    #[tokio::test]
974    async fn dev_sender_header_rejected_without_chain() {
975        let layer = SecurityLayer {
976            identities: Arc::new(HashMap::new()),
977            rate_bucket: Arc::new(RateBucket::default()),
978            auth_chain: None,
979            max_payload_bytes: 1_048_576,
980            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
981            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
982            session_start_rate: RateLimitConfig {
983                limit: usize::MAX,
984                window: Duration::from_secs(60),
985            },
986            message_rate: RateLimitConfig {
987                limit: usize::MAX,
988                window: Duration::from_secs(60),
989            },
990        };
991
992        let mut meta = MetadataMap::new();
993        meta.insert("x-macp-agent-id", "agent://dev-agent".parse().unwrap());
994
995        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
996        assert!(matches!(err, MacpError::Unauthenticated));
997    }
998
999    #[tokio::test]
1000    async fn dev_sender_header_ignored_when_not_allowed() {
1001        // allow_dev_sender_header=false, no tokens
1002        let layer = SecurityLayer {
1003            identities: Arc::new(HashMap::new()),
1004            rate_bucket: Arc::new(RateBucket::default()),
1005            auth_chain: None,
1006            max_payload_bytes: 1_048_576,
1007            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
1008            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
1009            session_start_rate: RateLimitConfig {
1010                limit: usize::MAX,
1011                window: Duration::from_secs(60),
1012            },
1013            message_rate: RateLimitConfig {
1014                limit: usize::MAX,
1015                window: Duration::from_secs(60),
1016            },
1017        };
1018
1019        let mut meta = MetadataMap::new();
1020        meta.insert("x-macp-agent-id", "agent://sneaky".parse().unwrap());
1021
1022        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
1023        assert!(matches!(err, MacpError::Unauthenticated));
1024    }
1025
1026    #[tokio::test]
1027    async fn bearer_token_takes_priority_over_dev_header() {
1028        let json = r#"[{"token":"real-tok","sender":"agent://real"}]"#;
1029        let identities = SecurityLayer::parse_identities(json).unwrap();
1030
1031        let layer = SecurityLayer {
1032            identities: Arc::new(identities),
1033            rate_bucket: Arc::new(RateBucket::default()),
1034            auth_chain: None,
1035            max_payload_bytes: 1_048_576,
1036            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
1037            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
1038            session_start_rate: RateLimitConfig {
1039                limit: usize::MAX,
1040                window: Duration::from_secs(60),
1041            },
1042            message_rate: RateLimitConfig {
1043                limit: usize::MAX,
1044                window: Duration::from_secs(60),
1045            },
1046        };
1047
1048        let mut meta = MetadataMap::new();
1049        meta.insert("authorization", "Bearer real-tok".parse().unwrap());
1050        meta.insert("x-macp-agent-id", "agent://dev-override".parse().unwrap());
1051
1052        let id = layer
1053            .authenticate_metadata(&meta)
1054            .await
1055            .expect("should authenticate via bearer");
1056        assert_eq!(id.sender, "agent://real");
1057    }
1058
1059    // ---------------------------------------------------------------
1060    // 5. authorize_mode() with allowed modes and without
1061    // ---------------------------------------------------------------
1062
1063    #[test]
1064    fn authorize_mode_allows_any_mode_when_no_restriction() {
1065        let layer = SecurityLayer::dev_mode();
1066        let id = AuthIdentity {
1067            sender: "agent://any".into(),
1068            allowed_modes: None,
1069            can_start_sessions: true,
1070            max_open_sessions: None,
1071            can_manage_mode_registry: false,
1072            is_observer: false,
1073        };
1074        assert!(layer
1075            .authorize_mode(&id, "macp.mode.decision.v1", false)
1076            .is_ok());
1077        assert!(layer.authorize_mode(&id, "macp.mode.task.v1", true).is_ok());
1078        assert!(layer.authorize_mode(&id, "arbitrary.mode", false).is_ok());
1079    }
1080
1081    #[test]
1082    fn authorize_mode_rejects_unlisted_mode() {
1083        let layer = SecurityLayer::dev_mode();
1084        let mut allowed = HashSet::new();
1085        allowed.insert("macp.mode.decision.v1".to_string());
1086
1087        let id = AuthIdentity {
1088            sender: "agent://restricted".into(),
1089            allowed_modes: Some(allowed),
1090            can_start_sessions: true,
1091            max_open_sessions: None,
1092            can_manage_mode_registry: false,
1093            is_observer: false,
1094        };
1095        assert!(layer
1096            .authorize_mode(&id, "macp.mode.decision.v1", false)
1097            .is_ok());
1098        let err = layer
1099            .authorize_mode(&id, "macp.mode.task.v1", false)
1100            .unwrap_err();
1101        assert!(matches!(err, MacpError::Forbidden));
1102    }
1103
1104    #[test]
1105    fn authorize_mode_rejects_session_start_when_not_allowed() {
1106        let layer = SecurityLayer::dev_mode();
1107        let id = AuthIdentity {
1108            sender: "agent://no-start".into(),
1109            allowed_modes: None,
1110            can_start_sessions: false,
1111            max_open_sessions: None,
1112            can_manage_mode_registry: false,
1113            is_observer: false,
1114        };
1115        let err = layer
1116            .authorize_mode(&id, "macp.mode.decision.v1", true)
1117            .unwrap_err();
1118        assert!(matches!(err, MacpError::Forbidden));
1119    }
1120
1121    #[test]
1122    fn authorize_mode_allows_non_session_start_even_when_start_forbidden() {
1123        let layer = SecurityLayer::dev_mode();
1124        let id = AuthIdentity {
1125            sender: "agent://no-start".into(),
1126            allowed_modes: None,
1127            can_start_sessions: false,
1128            max_open_sessions: None,
1129            can_manage_mode_registry: false,
1130            is_observer: false,
1131        };
1132        // Regular messages (not session start) should succeed
1133        assert!(layer
1134            .authorize_mode(&id, "macp.mode.decision.v1", false)
1135            .is_ok());
1136    }
1137
1138    #[test]
1139    fn authorize_mode_checks_both_can_start_and_allowed_modes() {
1140        let layer = SecurityLayer::dev_mode();
1141        let mut allowed = HashSet::new();
1142        allowed.insert("macp.mode.decision.v1".to_string());
1143
1144        let id = AuthIdentity {
1145            sender: "agent://double-check".into(),
1146            allowed_modes: Some(allowed),
1147            can_start_sessions: false,
1148            max_open_sessions: None,
1149            can_manage_mode_registry: false,
1150            is_observer: false,
1151        };
1152
1153        // Cannot start sessions (checked first)
1154        let err = layer
1155            .authorize_mode(&id, "macp.mode.decision.v1", true)
1156            .unwrap_err();
1157        assert!(matches!(err, MacpError::Forbidden));
1158
1159        // Cannot use unlisted mode
1160        let err = layer
1161            .authorize_mode(&id, "macp.mode.task.v1", false)
1162            .unwrap_err();
1163        assert!(matches!(err, MacpError::Forbidden));
1164
1165        // Can send non-start message on allowed mode
1166        assert!(layer
1167            .authorize_mode(&id, "macp.mode.decision.v1", false)
1168            .is_ok());
1169    }
1170
1171    #[test]
1172    fn authorize_mode_registry_requires_explicit_privilege() {
1173        let layer = SecurityLayer::dev_mode();
1174        let id = AuthIdentity {
1175            sender: "agent://no-admin".into(),
1176            allowed_modes: None,
1177            can_start_sessions: true,
1178            max_open_sessions: None,
1179            is_observer: false,
1180            can_manage_mode_registry: false,
1181        };
1182        let err = layer.authorize_mode_registry(&id).unwrap_err();
1183        assert!(matches!(err, MacpError::Forbidden));
1184    }
1185
1186    #[tokio::test]
1187    async fn bearer_token_can_manage_mode_registry() {
1188        let json =
1189            r#"[{"token":"admin-tok","sender":"agent://admin","can_manage_mode_registry":true}]"#;
1190        let layer = layer_with_tokens(json);
1191        let mut meta = MetadataMap::new();
1192        meta.insert("authorization", "Bearer admin-tok".parse().unwrap());
1193        let id = layer.authenticate_metadata(&meta).await.unwrap();
1194        assert!(layer.authorize_mode_registry(&id).is_ok());
1195    }
1196
1197    // ---------------------------------------------------------------
1198    // 6. enforce_rate_limit() with session_start and message categories
1199    // ---------------------------------------------------------------
1200
1201    #[tokio::test]
1202    async fn rate_limit_session_start_enforced() {
1203        let layer = SecurityLayer {
1204            identities: Arc::new(HashMap::new()),
1205            rate_bucket: Arc::new(RateBucket::default()),
1206            auth_chain: None,
1207            max_payload_bytes: 1_048_576,
1208            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
1209            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
1210            session_start_rate: RateLimitConfig {
1211                limit: 3,
1212                window: Duration::from_secs(60),
1213            },
1214            message_rate: RateLimitConfig {
1215                limit: usize::MAX,
1216                window: Duration::from_secs(60),
1217            },
1218        };
1219
1220        let sender = "agent://rate-test";
1221        // First 3 should succeed
1222        for _ in 0..3 {
1223            assert!(layer.enforce_rate_limit(sender, true).await.is_ok());
1224        }
1225        // 4th should be rate limited
1226        let err = layer.enforce_rate_limit(sender, true).await.unwrap_err();
1227        assert!(matches!(err, MacpError::RateLimited));
1228
1229        // Regular messages should still be fine (separate bucket)
1230        assert!(layer.enforce_rate_limit(sender, false).await.is_ok());
1231    }
1232
1233    #[tokio::test]
1234    async fn rate_limit_message_enforced() {
1235        let layer = SecurityLayer {
1236            identities: Arc::new(HashMap::new()),
1237            rate_bucket: Arc::new(RateBucket::default()),
1238            auth_chain: None,
1239            max_payload_bytes: 1_048_576,
1240            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
1241            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
1242            session_start_rate: RateLimitConfig {
1243                limit: usize::MAX,
1244                window: Duration::from_secs(60),
1245            },
1246            message_rate: RateLimitConfig {
1247                limit: 2,
1248                window: Duration::from_secs(60),
1249            },
1250        };
1251
1252        let sender = "agent://msg-test";
1253        assert!(layer.enforce_rate_limit(sender, false).await.is_ok());
1254        assert!(layer.enforce_rate_limit(sender, false).await.is_ok());
1255        let err = layer.enforce_rate_limit(sender, false).await.unwrap_err();
1256        assert!(matches!(err, MacpError::RateLimited));
1257
1258        // Session starts should still be fine (separate bucket)
1259        assert!(layer.enforce_rate_limit(sender, true).await.is_ok());
1260    }
1261
1262    #[tokio::test]
1263    async fn rate_limit_per_sender_isolation() {
1264        let layer = SecurityLayer {
1265            identities: Arc::new(HashMap::new()),
1266            rate_bucket: Arc::new(RateBucket::default()),
1267            auth_chain: None,
1268            max_payload_bytes: 1_048_576,
1269            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
1270            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
1271            session_start_rate: RateLimitConfig {
1272                limit: 1,
1273                window: Duration::from_secs(60),
1274            },
1275            message_rate: RateLimitConfig {
1276                limit: usize::MAX,
1277                window: Duration::from_secs(60),
1278            },
1279        };
1280
1281        // Sender A exhausts limit
1282        assert!(layer.enforce_rate_limit("agent://a", true).await.is_ok());
1283        assert!(layer.enforce_rate_limit("agent://a", true).await.is_err());
1284
1285        // Sender B should still be able to start sessions
1286        assert!(layer.enforce_rate_limit("agent://b", true).await.is_ok());
1287    }
1288
1289    #[tokio::test]
1290    async fn rate_limit_window_expiry() {
1291        let layer = SecurityLayer {
1292            identities: Arc::new(HashMap::new()),
1293            rate_bucket: Arc::new(RateBucket::default()),
1294            auth_chain: None,
1295            max_payload_bytes: 1_048_576,
1296            list_sessions_default_page_size: DEFAULT_LIST_SESSIONS_PAGE_SIZE,
1297            list_sessions_max_page_size: MAX_LIST_SESSIONS_PAGE_SIZE,
1298            session_start_rate: RateLimitConfig {
1299                limit: 1,
1300                window: Duration::from_millis(1), // very short window
1301            },
1302            message_rate: RateLimitConfig {
1303                limit: usize::MAX,
1304                window: Duration::from_secs(60),
1305            },
1306        };
1307
1308        let sender = "agent://expiry-test";
1309        assert!(layer.enforce_rate_limit(sender, true).await.is_ok());
1310
1311        // Wait for the window to expire
1312        tokio::time::sleep(Duration::from_millis(5)).await;
1313
1314        // Should succeed again after window expiry
1315        assert!(layer.enforce_rate_limit(sender, true).await.is_ok());
1316    }
1317
1318    // ---------------------------------------------------------------
1319    // 7. Anonymous fallback behavior
1320    // ---------------------------------------------------------------
1321
1322    #[tokio::test]
1323    async fn no_anonymous_fallback_even_when_auth_not_required() {
1324        let layer = insecure_layer();
1325        let meta = MetadataMap::new();
1326        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
1327        assert!(matches!(err, MacpError::Unauthenticated));
1328    }
1329
1330    #[tokio::test]
1331    async fn no_anonymous_fallback_when_auth_required() {
1332        let json = r#"[{"token":"t","sender":"agent://real"}]"#;
1333        let layer = layer_with_tokens(json);
1334
1335        let meta = MetadataMap::new();
1336        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
1337        assert!(matches!(err, MacpError::Unauthenticated));
1338    }
1339
1340    #[tokio::test]
1341    async fn dev_mode_no_fallback_with_empty_metadata() {
1342        // dev_mode: allow_dev_sender_header=true
1343        // With no headers at all, returns Unauthenticated (no anonymous fallback)
1344        let layer = SecurityLayer::dev_mode();
1345        let meta = MetadataMap::new();
1346        let err = layer.authenticate_metadata(&meta).await.unwrap_err();
1347        assert!(matches!(err, MacpError::Unauthenticated));
1348    }
1349
1350    // ---------------------------------------------------------------
1351    // 8. Token file loading via MACP_AUTH_TOKENS_FILE
1352    // ---------------------------------------------------------------
1353
1354    #[test]
1355    fn token_file_loading_via_parse_identities() {
1356        // Test the parse_identities path that from_env uses after reading the file.
1357        // We write a temp file and then read + parse it the same way from_env would.
1358        let json = r#"[
1359            {"token":"file-tok-1","sender":"agent://file-alice","allowed_modes":["macp.mode.decision.v1"]},
1360            {"token":"file-tok-2","sender":"agent://file-bob","can_start_sessions":false}
1361        ]"#;
1362        let mut tmp = NamedTempFile::new().expect("create temp file");
1363        write!(tmp, "{}", json).expect("write temp file");
1364
1365        let contents = fs::read_to_string(tmp.path()).expect("read temp file");
1366        let identities = SecurityLayer::parse_identities(&contents).expect("parse identities");
1367
1368        assert_eq!(identities.len(), 2);
1369
1370        let alice = identities.get("file-tok-1").expect("alice entry");
1371        assert_eq!(alice.sender, "agent://file-alice");
1372        let alice_modes = alice.allowed_modes.as_ref().expect("should have modes");
1373        assert!(alice_modes.contains("macp.mode.decision.v1"));
1374        assert!(alice.can_start_sessions); // default_true
1375
1376        let bob = identities.get("file-tok-2").expect("bob entry");
1377        assert_eq!(bob.sender, "agent://file-bob");
1378        assert!(!bob.can_start_sessions);
1379        assert!(bob.allowed_modes.is_none()); // empty vec -> None
1380    }
1381
1382    #[tokio::test]
1383    async fn token_file_end_to_end_via_layer() {
1384        // Build a layer as if loaded from a token file, then authenticate with it.
1385        let json = r#"[{"token":"e2e-tok","sender":"agent://e2e-agent"}]"#;
1386        let mut tmp = NamedTempFile::new().expect("create temp file");
1387        write!(tmp, "{}", json).expect("write temp file");
1388
1389        let contents = fs::read_to_string(tmp.path()).expect("read temp file");
1390        let layer = layer_with_tokens(&contents);
1391
1392        let mut meta = MetadataMap::new();
1393        meta.insert("authorization", "Bearer e2e-tok".parse().unwrap());
1394        let id = layer
1395            .authenticate_metadata(&meta)
1396            .await
1397            .expect("should authenticate");
1398        assert_eq!(id.sender, "agent://e2e-agent");
1399    }
1400
1401    #[test]
1402    fn parse_identities_invalid_json_returns_error() {
1403        let result = SecurityLayer::parse_identities("not valid json");
1404        assert!(result.is_err());
1405    }
1406
1407    #[test]
1408    fn parse_identities_empty_list() {
1409        let identities = SecurityLayer::parse_identities("[]").expect("valid empty list");
1410        assert!(identities.is_empty());
1411    }
1412
1413    #[test]
1414    fn parse_identities_wrapped_empty() {
1415        let identities =
1416            SecurityLayer::parse_identities(r#"{"tokens":[]}"#).expect("valid wrapped empty");
1417        assert!(identities.is_empty());
1418    }
1419
1420    /// The amortized sweep must actually bound the sender map: stale senders
1421    /// are fully removed when the periodic full sweep fires, so the map does
1422    /// not grow with total distinct-sender cardinality forever.
1423    #[tokio::test]
1424    async fn rate_bucket_sweep_removes_stale_senders() {
1425        let bucket = RateBucket::default();
1426        let config = RateLimitConfig {
1427            limit: 10,
1428            window: Duration::from_millis(1),
1429        };
1430
1431        // Tick 0 sweeps the (empty) map; ticks 1..=49 add 49 distinct senders.
1432        for i in 0..50 {
1433            SecurityLayer::check_bucket(
1434                &bucket.start_events,
1435                &bucket.start_sweep_counter,
1436                &format!("agent://stale-{i}"),
1437                &config,
1438            )
1439            .await
1440            .unwrap();
1441        }
1442        assert!(bucket.start_events.lock().await.len() >= 49);
1443
1444        // Let every recorded event age out of the window.
1445        tokio::time::sleep(Duration::from_millis(5)).await;
1446
1447        // Drive the counter across the next sweep boundary (tick 128) with a
1448        // single fresh sender. After the sweep only the fresh sender remains.
1449        for _ in 0..80 {
1450            SecurityLayer::check_bucket(
1451                &bucket.start_events,
1452                &bucket.start_sweep_counter,
1453                "agent://fresh",
1454                &config,
1455            )
1456            .await
1457            .ok(); // fresh sender may hit its own limit; irrelevant here
1458        }
1459        let map = bucket.start_events.lock().await;
1460        assert!(
1461            map.len() <= 2,
1462            "stale senders must be swept; map still has {} entries",
1463            map.len()
1464        );
1465        assert!(map.contains_key("agent://fresh"));
1466    }
1467}