Skip to main content

shell_tunnel/api/
router.rs

1//! API router configuration.
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5
6use axum::{
7    extract::{connect_info::IntoMakeServiceWithConnectInfo, MatchedPath, Request, State},
8    http::{header::AUTHORIZATION, Method, StatusCode},
9    middleware::{self, Next},
10    response::Response,
11    routing::{any, get, post},
12    Router,
13};
14use tower_http::{
15    cors::{Any, CorsLayer},
16    trace::TraceLayer,
17};
18
19use super::handlers::{
20    api_info, create_session, delete_session, execute_command, execute_oneshot, get_session,
21    health, list_sessions, AppState,
22};
23use super::websocket::{ws_handler, ws_oneshot_handler};
24use crate::security::{
25    rate_limit_middleware, ApiKeyStore, AuthConfig, CapabilitySet, RateLimitConfig, RateLimiter,
26};
27
28/// Cross-Origin Resource Sharing (CORS) configuration.
29///
30/// CORS is a browser-enforced mechanism; non-browser clients (`curl`, SDKs)
31/// ignore it entirely. shell-tunnel therefore emits **no** permissive CORS headers
32/// by default: this blocks a malicious web page from reading responses cross-origin
33/// and, because the JSON execute endpoints require a preflight, from issuing the
34/// request at all — with zero impact on the intended non-browser consumers.
35///
36/// Note: CORS does **not** defend against DNS-rebinding (the attacker's host is
37/// rebound to `127.0.0.1`, making the request same-origin); that requires
38/// Host-header validation and is tracked separately.
39#[derive(Debug, Clone, Default)]
40pub struct CorsConfig {
41    /// Allow any origin/method/header (restores the permissive `Any` behavior).
42    /// Off by default; enable only for trusted browser-based UIs.
43    pub allow_any: bool,
44}
45
46/// Security configuration for the server.
47#[derive(Debug, Clone)]
48pub struct SecurityConfig {
49    /// Authentication configuration.
50    pub auth: AuthConfig,
51    /// Rate limiting configuration.
52    pub rate_limit: RateLimitConfig,
53    /// API keys to pre-register.
54    pub api_keys: Vec<String>,
55    /// Capabilities granted to the pre-registered keys and to the
56    /// auto-generated fallback key.
57    ///
58    /// `None` (the default) means **full-control** (wildcard) — the backward-
59    /// compatible behavior for a bare `--api-key` / `--require-auth` (spec §4).
60    /// `Some(set)` issues fine-grained tokens scoped to that set (spec §9).
61    pub capabilities: Option<CapabilitySet>,
62    /// CORS configuration.
63    pub cors: CorsConfig,
64    /// Host names this server answers to, when it is worth checking.
65    ///
66    /// `None` disables the check. It is meant for a loopback-bound server, where
67    /// DNS rebinding is the one attack CORS cannot stop: the attacker's name is
68    /// rebound to `127.0.0.1`, so the browser considers the request same-origin
69    /// and sends it. The `Host` header still carries the attacker's name, which
70    /// is what this compares. A published server is deliberately reachable under
71    /// a name we may not know, so the check does not apply there.
72    pub allowed_hosts: Option<Vec<String>>,
73}
74
75impl Default for SecurityConfig {
76    fn default() -> Self {
77        Self {
78            auth: AuthConfig::disabled(), // Disabled by default for ease of use
79            rate_limit: RateLimitConfig::default(),
80            api_keys: Vec::new(),
81            capabilities: None, // Full-control by default (legacy-compatible)
82            cors: CorsConfig::default(), // Restrictive by default (no permissive CORS)
83            allowed_hosts: None,
84        }
85    }
86}
87
88/// Build a permissive CORS layer when `allow_any` is set, otherwise `None`.
89///
90/// Returning `None` means no CORS headers are emitted — the secure default.
91fn cors_layer(cfg: &CorsConfig) -> Option<CorsLayer> {
92    cfg.allow_any.then(|| {
93        CorsLayer::new()
94            .allow_origin(Any)
95            .allow_methods(Any)
96            .allow_headers(Any)
97    })
98}
99
100impl SecurityConfig {
101    /// Create a secure configuration.
102    pub fn secure() -> Self {
103        Self {
104            auth: AuthConfig::default(),
105            rate_limit: RateLimitConfig::default(),
106            api_keys: Vec::new(),
107            capabilities: None,
108            cors: CorsConfig::default(),
109            allowed_hosts: None,
110        }
111    }
112
113    /// Create a development configuration (no auth, relaxed limits).
114    pub fn development() -> Self {
115        Self {
116            auth: AuthConfig::disabled(),
117            rate_limit: RateLimitConfig::relaxed(),
118            api_keys: Vec::new(),
119            capabilities: None,
120            cors: CorsConfig::default(),
121            allowed_hosts: None,
122        }
123    }
124
125    /// Add an API key.
126    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
127        self.api_keys.push(key.into());
128        self
129    }
130
131    /// Scope the issued tokens to a fine-grained capability set (spec §9).
132    ///
133    /// Applies to the pre-registered keys and to the auto-generated fallback
134    /// key. Without this, tokens are full-control (legacy-compatible).
135    pub fn with_capabilities(mut self, capabilities: CapabilitySet) -> Self {
136        self.capabilities = Some(capabilities);
137        self
138    }
139
140    /// Answer only to these host names.
141    pub fn with_allowed_hosts(mut self, hosts: Vec<String>) -> Self {
142        self.allowed_hosts = Some(hosts);
143        self
144    }
145
146    /// Enable permissive (`Any`) CORS. Opt-in; only for trusted browser UIs.
147    pub fn with_cors_allow_any(mut self) -> Self {
148        self.cors.allow_any = true;
149        self
150    }
151}
152
153/// Register `key` into `store` with `capabilities` (fine-grained), or as a
154/// legacy full-control key when `capabilities` is `None` (spec §4/§9).
155fn register_key(store: &ApiKeyStore, key: &str, capabilities: &Option<CapabilitySet>) {
156    match capabilities {
157        Some(caps) => store.add_key_with_capabilities(key, caps.clone(), "configured"),
158        None => store.add_key(key),
159    }
160}
161
162/// Create the API router with all routes configured.
163pub fn create_router() -> Router {
164    create_router_with_state(AppState::new())
165}
166
167/// Create the API router with custom state (no security).
168pub fn create_router_with_state(state: AppState) -> Router {
169    // Session routes
170    let session_routes = Router::new()
171        .route("/", get(list_sessions).post(create_session))
172        .route("/{id}", get(get_session).delete(delete_session))
173        .route("/{id}/execute", post(execute_command))
174        .route("/{id}/ws", any(ws_handler));
175
176    // API v1 routes
177    let api_v1 = Router::new()
178        .route("/", get(api_info))
179        .route("/execute", post(execute_oneshot))
180        .route("/ws", any(ws_oneshot_handler))
181        .nest("/sessions", session_routes);
182
183    // Build main router. This "no security" convenience constructor uses the
184    // restrictive CORS default (no permissive CORS headers emitted).
185    Router::new()
186        .route("/health", get(health))
187        .nest("/api/v1", api_v1)
188        .layer(TraceLayer::new_for_http())
189        .with_state(state)
190}
191
192/// The capability a route requires (Phase A spec §3).
193///
194/// Declared here at the router layer — co-located with the route definitions,
195/// which are the single source of truth for the matched-path strings this maps
196/// against. Not a per-handler attribute.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum RequiredCapability {
199    /// No authentication at all (e.g. `/health`).
200    Public,
201    /// Authenticated only: any valid token passes, no specific capability
202    /// required (spec §3 "인증만" tier, e.g. `GET /api/v1`).
203    Authenticated,
204    /// Requires a specific capability string (set membership, or wildcard).
205    Capability(&'static str),
206}
207
208/// Map a matched route (`method` + axum [`MatchedPath`]) to its required
209/// capability (spec §3 table).
210///
211/// Keyed on the **full nested** `MatchedPath` (confirmed against the real router
212/// structure) plus the HTTP method, because one path can require different
213/// capabilities per method (e.g. `GET /api/v1/sessions` = read, `POST` = manage).
214///
215/// Unknown routes fail **closed** to [`RequiredCapability::Authenticated`]: an
216/// unmapped route still requires a valid token, never less than that.
217pub fn required_capability(method: &Method, matched_path: &str) -> RequiredCapability {
218    use RequiredCapability::{Authenticated, Capability, Public};
219
220    match (method, matched_path) {
221        (_, "/health") => Public,
222        (&Method::GET, "/api/v1") => Authenticated,
223        (&Method::POST, "/api/v1/execute") => Capability("exec"),
224        (_, "/api/v1/ws") => Capability("exec"),
225        (&Method::GET, "/api/v1/sessions") => Capability("session.read"),
226        (&Method::POST, "/api/v1/sessions") => Capability("session.manage"),
227        (&Method::GET, "/api/v1/sessions/{id}") => Capability("session.read"),
228        (&Method::DELETE, "/api/v1/sessions/{id}") => Capability("session.manage"),
229        (&Method::POST, "/api/v1/sessions/{id}/execute") => Capability("exec"),
230        (_, "/api/v1/sessions/{id}/ws") => Capability("exec"),
231        _ => Authenticated,
232    }
233}
234
235/// Scope-aware authentication + authorization middleware (spec §5).
236///
237/// 1. Resolve the route's required capability from `method` + `MatchedPath`.
238/// 2. `Public` route or auth disabled → pass through.
239/// 3. Extract the bearer token; look up its `TokenRecord` in the store.
240///    Missing/invalid token → **401**.
241/// 4. `Authenticated` route → any valid token passes. `Capability(c)` route →
242///    the token's set must satisfy `c` (membership or wildcard), else **403**.
243async fn capability_auth_middleware(
244    State((store, audit)): State<(
245        std::sync::Arc<ApiKeyStore>,
246        std::sync::Arc<crate::audit::AuditSink>,
247    )>,
248    mut request: Request,
249    next: Next,
250) -> Result<Response, StatusCode> {
251    // Auth disabled → open server (existing behavior).
252    if !store.is_enabled() {
253        return Ok(next.run(request).await);
254    }
255
256    let method = request.method().clone();
257    // Owned so it can be used in the rejection logs after `request` is consumed.
258    let matched = request
259        .extensions()
260        .get::<MatchedPath>()
261        .map(|m| m.as_str().to_owned())
262        .unwrap_or_default();
263    let required = required_capability(&method, &matched);
264
265    // Public routes (e.g. /health) skip auth entirely.
266    if required == RequiredCapability::Public {
267        return Ok(next.run(request).await);
268    }
269
270    // Extract the bearer token and resolve its capabilities.
271    // Missing header, wrong prefix, or unregistered token → 401.
272    let token = request
273        .headers()
274        .get(AUTHORIZATION)
275        .and_then(|v| v.to_str().ok())
276        .and_then(|header| store.extract_key(header));
277
278    let identity = token.as_deref().and_then(|t| store.identity(t));
279
280    let capabilities = match token.as_deref().and_then(|t| store.capabilities(t)) {
281        Some(caps) => caps,
282        None => {
283            // Logged at debug to avoid a log-flood amplifier under probing; the
284            // token value itself is never logged. `missing-token` = no/malformed
285            // Authorization header, `invalid-token` = present but unregistered.
286            let reason = if token.is_none() {
287                "missing-token"
288            } else {
289                "invalid-token"
290            };
291            tracing::debug!(%method, path = %matched, reason, "auth rejected (401)");
292            // Probing is exactly what an audit trail is asked about afterwards,
293            // so refusals are recorded as well as successes.
294            audit.record(
295                crate::audit::AuditEvent::new("denied")
296                    .with_route(format!("{method} {matched}"))
297                    .with_denial(401, reason),
298            );
299            return Err(StatusCode::UNAUTHORIZED);
300        }
301    };
302
303    // Handlers record what actually ran, and need to know who asked.
304    if let Some(identity) = identity.clone() {
305        request.extensions_mut().insert(identity);
306    }
307
308    match required {
309        // Already handled above, but keep the match exhaustive.
310        RequiredCapability::Public => Ok(next.run(request).await),
311        // Any valid token satisfies an authenticated-only route.
312        RequiredCapability::Authenticated => Ok(next.run(request).await),
313        // Specific capability: set membership (or wildcard) required, else 403.
314        RequiredCapability::Capability(cap) => {
315            if capabilities.satisfies(cap) {
316                Ok(next.run(request).await)
317            } else {
318                audit.record(
319                    crate::audit::AuditEvent::new("denied")
320                        .with_identity(identity)
321                        .with_route(format!("{method} {matched}"))
322                        .with_denial(403, format!("missing-capability:{cap}")),
323                );
324                tracing::debug!(
325                    %method,
326                    path = %matched,
327                    required = cap,
328                    "authorization denied (403): insufficient capability"
329                );
330                Err(StatusCode::FORBIDDEN)
331            }
332        }
333    }
334}
335
336/// Whether `header` names a host this server answers to.
337///
338/// Compared without the port, since the port is not what an attacker controls
339/// in a rebinding attack, and a legitimate caller may reach the same server
340/// through different ports.
341fn host_is_allowed(header: Option<&str>, allowed: &[String]) -> bool {
342    let Some(value) = header else {
343        // HTTP/1.1 requires a Host header; its absence is not a shape any
344        // ordinary client produces.
345        return false;
346    };
347
348    let host = value
349        .rsplit_once(':')
350        .map_or(value, |(host, port)| {
351            // Only strip a trailing port, not part of a bare IPv6 address.
352            if port.chars().all(|c| c.is_ascii_digit()) {
353                host
354            } else {
355                value
356            }
357        })
358        .trim_matches(|c| c == '[' || c == ']');
359
360    allowed
361        .iter()
362        .any(|candidate| candidate.eq_ignore_ascii_case(host))
363}
364
365/// Reject requests carrying a `Host` this server does not answer to.
366async fn host_check_middleware(
367    State(allowed): State<Arc<Vec<String>>>,
368    request: Request,
369    next: Next,
370) -> Result<Response, (StatusCode, String)> {
371    let header = request
372        .headers()
373        .get(axum::http::header::HOST)
374        .and_then(|value| value.to_str().ok());
375
376    if host_is_allowed(header, &allowed) {
377        return Ok(next.run(request).await);
378    }
379
380    // Named explicitly: an operator hitting this from a container or behind a
381    // proxy needs to know which name was refused and how to permit it.
382    let seen = header.unwrap_or("(none)").to_string();
383    tracing::debug!(host = %seen, "request refused: host not allowed");
384    Err((
385        StatusCode::FORBIDDEN,
386        format!(
387            "host {seen} is not allowed; pass --allow-host {seen} to permit it
388"
389        ),
390    ))
391}
392
393/// Create the API router with security enabled.
394pub fn create_secure_router(
395    state: AppState,
396    security: SecurityConfig,
397) -> (Router, Arc<ApiKeyStore>, Arc<RateLimiter>) {
398    // Create security components
399    let auth_store = Arc::new(ApiKeyStore::new(security.auth));
400    let rate_limiter = Arc::new(RateLimiter::new(security.rate_limit));
401
402    // Register API keys with their configured capabilities.
403    for key in &security.api_keys {
404        register_key(&auth_store, key, &security.capabilities);
405    }
406
407    // Session routes
408    let session_routes = Router::new()
409        .route("/", get(list_sessions).post(create_session))
410        .route("/{id}", get(get_session).delete(delete_session))
411        .route("/{id}/execute", post(execute_command))
412        .route("/{id}/ws", any(ws_handler));
413
414    // API v1 routes
415    let api_v1 = Router::new()
416        .route("/", get(api_info))
417        .route("/execute", post(execute_oneshot))
418        .route("/ws", any(ws_oneshot_handler))
419        .nest("/sessions", session_routes);
420
421    let allowed_hosts = security.allowed_hosts.clone();
422
423    // Build main router with security layers
424    let mut router = Router::new()
425        .route("/health", get(health))
426        .nest("/api/v1", api_v1)
427        .layer(middleware::from_fn_with_state(
428            (Arc::clone(&auth_store), Arc::clone(&state.audit)),
429            capability_auth_middleware,
430        ))
431        .layer(middleware::from_fn_with_state(
432            Arc::clone(&rate_limiter),
433            rate_limit_middleware,
434        ))
435        .layer(TraceLayer::new_for_http());
436
437    // Outermost, so a rebound request is refused before it reaches the token
438    // store or the rate limiter's bookkeeping.
439    if let Some(hosts) = allowed_hosts {
440        router = router.layer(middleware::from_fn_with_state(
441            Arc::new(hosts),
442            host_check_middleware,
443        ));
444    }
445
446    // Permissive CORS only when explicitly opted in (default: restrictive).
447    if let Some(cors) = cors_layer(&security.cors) {
448        router = router.layer(cors);
449    }
450
451    let router = router.with_state(state);
452
453    (router, auth_store, rate_limiter)
454}
455
456/// Server configuration.
457#[derive(Debug, Clone)]
458pub struct ServerConfig {
459    /// Host address to bind to.
460    pub host: String,
461    /// Port to listen on.
462    pub port: u16,
463    /// Security configuration.
464    pub security: SecurityConfig,
465    /// Enable graceful shutdown on SIGTERM/SIGINT.
466    pub graceful_shutdown: bool,
467}
468
469impl ServerConfig {
470    pub fn new(host: impl Into<String>, port: u16) -> Self {
471        Self {
472            host: host.into(),
473            port,
474            security: SecurityConfig::default(),
475            graceful_shutdown: true,
476        }
477    }
478
479    pub fn bind_address(&self) -> String {
480        format!("{}:{}", self.host, self.port)
481    }
482
483    /// Enable security with the given configuration.
484    pub fn with_security(mut self, security: SecurityConfig) -> Self {
485        self.security = security;
486        self
487    }
488
489    /// Disable graceful shutdown.
490    pub fn without_graceful_shutdown(mut self) -> Self {
491        self.graceful_shutdown = false;
492        self
493    }
494}
495
496impl Default for ServerConfig {
497    fn default() -> Self {
498        Self {
499            host: "127.0.0.1".to_string(),
500            port: 3000,
501            security: SecurityConfig::default(),
502            graceful_shutdown: true,
503        }
504    }
505}
506
507/// Start the API server.
508pub async fn serve(config: ServerConfig) -> crate::Result<()> {
509    serve_with_state(config, AppState::new()).await
510}
511
512/// Bind the API server's port without starting to serve.
513///
514/// Callers that need to know the port before traffic flows — anything binding
515/// port 0, where the OS chooses — take the listener from here and hand it to
516/// [`serve_on`]. Splitting bind from serve is what makes an ephemeral port
517/// usable: the alternative is binding twice and racing whoever grabs it in
518/// between.
519pub async fn bind(config: &ServerConfig) -> crate::Result<tokio::net::TcpListener> {
520    tokio::net::TcpListener::bind(config.bind_address())
521        .await
522        .map_err(crate::error::ShellTunnelError::Io)
523}
524
525/// Start the API server with custom state.
526pub async fn serve_with_state(config: ServerConfig, state: AppState) -> crate::Result<()> {
527    let listener = bind(&config).await?;
528    serve_on(listener, config, state).await
529}
530
531/// Serve on an already-bound listener.
532pub async fn serve_on(
533    listener: tokio::net::TcpListener,
534    config: ServerConfig,
535    state: AppState,
536) -> crate::Result<()> {
537    let addr = config.bind_address();
538
539    // Create router with security
540    let (router, auth_store, _rate_limiter) = create_secure_router(state, config.security.clone());
541
542    // Log API key if auth is enabled and keys are registered
543    if auth_store.is_enabled() {
544        if auth_store.count() == 0 {
545            // Generate and register a key if none provided, scoped to the
546            // configured capabilities (full-control when unset).
547            let key = crate::security::generate_api_key();
548            register_key(&auth_store, &key, &config.security.capabilities);
549            tracing::info!("Generated API key: {}", key);
550        }
551        tracing::info!(
552            "Authentication enabled with {} API key(s)",
553            auth_store.count()
554        );
555    } else {
556        tracing::warn!("Authentication is DISABLED - server is open to all requests");
557    }
558
559    let _ = addr;
560    tracing::info!(
561        "Starting shell-tunnel API server on {}",
562        listener
563            .local_addr()
564            .map(|a| a.to_string())
565            .unwrap_or_else(|_| config.bind_address())
566    );
567
568    // Create service with connection info for rate limiting
569    let service: IntoMakeServiceWithConnectInfo<Router, SocketAddr> =
570        router.into_make_service_with_connect_info::<SocketAddr>();
571
572    if config.graceful_shutdown {
573        // Serve with graceful shutdown
574        axum::serve(listener, service)
575            .with_graceful_shutdown(shutdown_signal())
576            .await
577            .map_err(|e| {
578                crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
579            })?;
580
581        tracing::info!("Server shutdown complete");
582    } else {
583        // Serve without graceful shutdown
584        axum::serve(listener, service).await.map_err(|e| {
585            crate::error::ShellTunnelError::Io(std::io::Error::other(e.to_string()))
586        })?;
587    }
588
589    Ok(())
590}
591
592/// Wait for shutdown signal (Ctrl+C or SIGTERM).
593async fn shutdown_signal() {
594    let ctrl_c = async {
595        tokio::signal::ctrl_c()
596            .await
597            .expect("Failed to install Ctrl+C handler");
598    };
599
600    #[cfg(unix)]
601    let terminate = async {
602        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
603            .expect("Failed to install SIGTERM handler")
604            .recv()
605            .await;
606    };
607
608    #[cfg(not(unix))]
609    let terminate = std::future::pending::<()>();
610
611    tokio::select! {
612        _ = ctrl_c => {
613            tracing::info!("Received Ctrl+C, initiating graceful shutdown...");
614        }
615        _ = terminate => {
616            tracing::info!("Received SIGTERM, initiating graceful shutdown...");
617        }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_server_config_default() {
627        let config = ServerConfig::default();
628        assert_eq!(config.host, "127.0.0.1");
629        assert_eq!(config.port, 3000);
630        assert_eq!(config.bind_address(), "127.0.0.1:3000");
631        assert!(config.graceful_shutdown);
632    }
633
634    #[test]
635    fn test_server_config_custom() {
636        let config = ServerConfig::new("0.0.0.0", 8080);
637        assert_eq!(config.bind_address(), "0.0.0.0:8080");
638    }
639
640    #[test]
641    fn test_server_config_with_security() {
642        let config = ServerConfig::new("0.0.0.0", 8080)
643            .with_security(SecurityConfig::secure().with_api_key("test-key"));
644
645        assert!(config.security.auth.enabled);
646        assert_eq!(config.security.api_keys.len(), 1);
647    }
648
649    #[test]
650    fn test_security_config_default() {
651        let config = SecurityConfig::default();
652        assert!(!config.auth.enabled); // Disabled by default
653        assert!(config.rate_limit.enabled);
654    }
655
656    #[test]
657    fn test_security_config_secure() {
658        let config = SecurityConfig::secure();
659        assert!(config.auth.enabled);
660        assert!(config.rate_limit.enabled);
661    }
662
663    #[test]
664    fn test_cors_restrictive_by_default() {
665        assert!(!SecurityConfig::default().cors.allow_any);
666        assert!(!SecurityConfig::secure().cors.allow_any);
667        assert!(cors_layer(&CorsConfig::default()).is_none());
668    }
669
670    #[test]
671    fn test_cors_allow_any_opt_in() {
672        let config = SecurityConfig::development().with_cors_allow_any();
673        assert!(config.cors.allow_any);
674        assert!(cors_layer(&config.cors).is_some());
675    }
676
677    #[test]
678    fn test_security_config_development() {
679        let config = SecurityConfig::development();
680        assert!(!config.auth.enabled);
681        assert!(config.rate_limit.enabled);
682    }
683
684    #[test]
685    fn test_router_creation() {
686        let _router = create_router();
687        // Router created successfully
688    }
689
690    #[test]
691    fn test_required_capability_mapping() {
692        use RequiredCapability::{Authenticated, Capability, Public};
693
694        // Public + authenticated-only tiers.
695        assert_eq!(required_capability(&Method::GET, "/health"), Public);
696        assert_eq!(required_capability(&Method::GET, "/api/v1"), Authenticated);
697
698        // exec routes (oneshot + session-scoped + WS).
699        assert_eq!(
700            required_capability(&Method::POST, "/api/v1/execute"),
701            Capability("exec")
702        );
703        assert_eq!(
704            required_capability(&Method::GET, "/api/v1/ws"),
705            Capability("exec")
706        );
707        assert_eq!(
708            required_capability(&Method::POST, "/api/v1/sessions/{id}/execute"),
709            Capability("exec")
710        );
711        assert_eq!(
712            required_capability(&Method::GET, "/api/v1/sessions/{id}/ws"),
713            Capability("exec")
714        );
715
716        // read vs manage split on the same path, keyed by method.
717        assert_eq!(
718            required_capability(&Method::GET, "/api/v1/sessions"),
719            Capability("session.read")
720        );
721        assert_eq!(
722            required_capability(&Method::POST, "/api/v1/sessions"),
723            Capability("session.manage")
724        );
725        assert_eq!(
726            required_capability(&Method::GET, "/api/v1/sessions/{id}"),
727            Capability("session.read")
728        );
729        assert_eq!(
730            required_capability(&Method::DELETE, "/api/v1/sessions/{id}"),
731            Capability("session.manage")
732        );
733    }
734
735    #[test]
736    fn test_required_capability_unknown_fails_closed() {
737        // An unmapped route requires at least a valid token (never less).
738        assert_eq!(
739            required_capability(&Method::GET, "/api/v1/unknown"),
740            RequiredCapability::Authenticated
741        );
742    }
743
744    #[test]
745    fn test_secure_router_creation() {
746        let state = AppState::new();
747        let security = SecurityConfig::secure().with_api_key("test-key");
748        let (router, auth_store, rate_limiter) = create_secure_router(state, security);
749
750        assert_eq!(auth_store.count(), 1);
751        assert!(auth_store.is_valid("test-key"));
752        assert!(rate_limiter.is_enabled());
753
754        // Router should be created
755        drop(router);
756    }
757}