Skip to main content

autumn_web/
tenancy.rs

1use axum::{
2    extract::State,
3    http::Request,
4    middleware::Next,
5    response::{IntoResponse, Response},
6};
7use http_body::Body as HttpBody;
8use pin_project_lite::pin_project;
9use secrecy::ExposeSecret;
10use std::future::Future;
11use std::pin::Pin;
12use std::task::{Context, Poll};
13
14// 1. Task-local storage for CURRENT_TENANT
15tokio::task_local! {
16    pub static CURRENT_TENANT: Option<String>;
17}
18
19// 2. Extractor structure
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Tenant(pub String);
22
23impl axum::extract::FromRequestParts<crate::AppState> for Tenant {
24    type Rejection = crate::AutumnError;
25
26    async fn from_request_parts(
27        parts: &mut axum::http::request::Parts,
28        state: &crate::AppState,
29    ) -> Result<Self, Self::Rejection> {
30        // Fast path: when the tenancy middleware has already resolved and scoped
31        // the tenant for this request, read it from the task-local rather than
32        // performing a second extraction from headers/session/cookies.
33        if let Ok(Some(tenant_id)) = CURRENT_TENANT.try_with(Clone::clone) {
34            return Ok(Self(tenant_id));
35        }
36
37        let config = state
38            .extension::<crate::config::AutumnConfig>()
39            .ok_or_else(|| {
40                crate::AutumnError::service_unavailable_msg("Config is not available")
41            })?;
42        let tenant_id = extract_tenant_from_parts(parts, &config).await?;
43        Ok(Self(tenant_id))
44    }
45}
46
47// Helper to run in-test tenancy contexts
48pub async fn with_tenant<F, R>(tenant_id: String, future: F) -> R
49where
50    F: Future<Output = R>,
51{
52    CURRENT_TENANT.scope(Some(tenant_id), future).await
53}
54
55// Tenant extraction logic based on configuration
56#[allow(clippy::missing_errors_doc, clippy::too_many_lines)]
57pub async fn extract_tenant_from_parts(
58    parts: &mut axum::http::request::Parts,
59    config: &crate::config::AutumnConfig,
60) -> Result<String, crate::AutumnError> {
61    if !config.tenancy.enabled {
62        return Err(crate::AutumnError::service_unavailable_msg(
63            "Tenancy is not enabled; set [tenancy] enabled = true in autumn.toml",
64        ));
65    }
66
67    match config.tenancy.source.as_str() {
68        "header" => {
69            let header_value = parts
70                .headers
71                .get(&config.tenancy.header_name)
72                .ok_or_else(|| {
73                    crate::AutumnError::bad_request_msg(format!(
74                        "Missing required tenant header: {}",
75                        config.tenancy.header_name
76                    ))
77                })?;
78            let val = header_value
79                .to_str()
80                .map_err(|_| {
81                    crate::AutumnError::bad_request_msg(format!(
82                        "Invalid UTF-8 in tenant header: {}",
83                        config.tenancy.header_name
84                    ))
85                })?
86                .to_string();
87            if val.trim().is_empty() {
88                return Err(crate::AutumnError::bad_request_msg(format!(
89                    "Tenant header {} is empty",
90                    config.tenancy.header_name
91                )));
92            }
93            Ok(val)
94        }
95        "subdomain" => {
96            // Prefer the proxy-resolved host (honours X-Forwarded-Host from trusted
97            // upstreams); fall back to the raw Host header when the layer has not run.
98            let host_owned: String = parts
99                .extensions
100                .get::<crate::security::ResolvedClientIdentity>()
101                .and_then(|id| id.host.clone())
102                .map_or_else(
103                    || {
104                        parts
105                            .headers
106                            .get(axum::http::header::HOST)
107                            .ok_or_else(|| {
108                                crate::AutumnError::bad_request_msg(
109                                    "Missing Host header for subdomain tenancy",
110                                )
111                            })
112                            .and_then(|h| {
113                                h.to_str().map(ToOwned::to_owned).map_err(|_| {
114                                    crate::AutumnError::bad_request_msg(
115                                        "Invalid UTF-8 in Host header",
116                                    )
117                                })
118                            })
119                    },
120                    Ok,
121                )?;
122
123            let host = host_owned.as_str();
124            let host_only = host.split(':').next().unwrap_or(host).trim();
125
126            if host_only.parse::<std::net::IpAddr>().is_ok() {
127                return Err(crate::AutumnError::bad_request_msg(
128                    "IP address host not allowed in subdomain mode",
129                ));
130            }
131
132            // DNS hostnames are case-insensitive; normalise to lowercase
133            // before any matching so that e.g. `Tenant1.Example.COM` works.
134            let host_lower = host_only.to_lowercase();
135
136            if let Some(ref base_domain) = config.tenancy.base_domain {
137                let base_domain_clean = base_domain.trim().to_lowercase();
138                if !host_lower.ends_with(base_domain_clean.as_str()) {
139                    return Err(crate::AutumnError::bad_request_msg(format!(
140                        "Host does not match base domain: {base_domain_clean}"
141                    )));
142                }
143                if host_lower.len() <= base_domain_clean.len() {
144                    return Err(crate::AutumnError::bad_request_msg(
145                        "Apex domain not allowed in subdomain mode",
146                    ));
147                }
148                let prefix_len = host_lower.len() - base_domain_clean.len();
149                if !host_lower[..prefix_len].ends_with('.') {
150                    return Err(crate::AutumnError::bad_request_msg(
151                        "Invalid subdomain format",
152                    ));
153                }
154                let subdomain_part = &host_lower[..prefix_len - 1];
155                let tenant = subdomain_part.split('.').next().ok_or_else(|| {
156                    crate::AutumnError::bad_request_msg("Unable to extract subdomain tenant")
157                })?;
158                if tenant.trim().is_empty() {
159                    return Err(crate::AutumnError::bad_request_msg(
160                        "Extracted subdomain tenant is empty",
161                    ));
162                }
163                Ok(tenant.to_string())
164            } else {
165                let labels: Vec<&str> = host_lower.split('.').filter(|s| !s.is_empty()).collect();
166                if labels.is_empty() {
167                    return Err(crate::AutumnError::bad_request_msg("Empty host header"));
168                }
169
170                if labels.len() < 2 {
171                    return Err(crate::AutumnError::bad_request_msg(
172                        "Apex or local host without subdomain not allowed",
173                    ));
174                }
175
176                if labels.len() == 2 && labels[1] != "localhost" {
177                    return Err(crate::AutumnError::bad_request_msg(
178                        "Apex domain not allowed in subdomain mode",
179                    ));
180                }
181
182                let tenant = labels[0].to_string();
183                if tenant.trim().is_empty() {
184                    return Err(crate::AutumnError::bad_request_msg(
185                        "Extracted subdomain tenant is empty",
186                    ));
187                }
188                Ok(tenant)
189            }
190        }
191        "session" => {
192            let session = parts
193                .extensions
194                .get::<crate::session::Session>()
195                .ok_or_else(|| {
196                    crate::AutumnError::internal_server_error_msg(
197                        "SessionLayer not installed but session tenancy source is configured",
198                    )
199                })?;
200            let tenant = session
201                .get(&config.tenancy.session_key)
202                .await
203                .ok_or_else(|| {
204                    crate::AutumnError::unauthorized_msg(format!(
205                        "Tenant ID missing from session key: {}",
206                        config.tenancy.session_key
207                    ))
208                })?;
209            if tenant.trim().is_empty() {
210                return Err(crate::AutumnError::unauthorized_msg(format!(
211                    "Tenant ID in session key {} is empty",
212                    config.tenancy.session_key
213                )));
214            }
215            Ok(tenant)
216        }
217        "jwt" => {
218            let auth_header = parts
219                .headers
220                .get(axum::http::header::AUTHORIZATION)
221                .ok_or_else(|| {
222                    crate::AutumnError::unauthorized_msg(
223                        "Missing Authorization header for JWT tenancy",
224                    )
225                })?;
226            let auth_str = auth_header.to_str().map_err(|_| {
227                crate::AutumnError::unauthorized_msg("Invalid UTF-8 in Authorization header")
228            })?;
229
230            if auth_str.len() < 7
231                || !auth_str.is_char_boundary(7)
232                || !auth_str[..7].eq_ignore_ascii_case("bearer ")
233            {
234                return Err(crate::AutumnError::unauthorized_msg(
235                    "Invalid Authorization header format. Expected Bearer <token>",
236                ));
237            }
238            let token = &auth_str[7..];
239
240            let secret = config.tenancy.jwt_secret.as_ref().ok_or_else(|| {
241                crate::AutumnError::unauthorized_msg("JWT secret is not configured")
242            })?;
243
244            let mut validation = ::jsonwebtoken::Validation::default();
245            if let Some(ref iss) = config.tenancy.jwt_issuer {
246                validation.set_issuer(::std::slice::from_ref(iss));
247            }
248            if let Some(ref aud) = config.tenancy.jwt_audience {
249                validation.set_audience(&[aud.as_str()]);
250            } else {
251                validation.validate_aud = false;
252            }
253
254            let token_data = ::jsonwebtoken::decode::<serde_json::Value>(
255                token,
256                &::jsonwebtoken::DecodingKey::from_secret(secret.expose_secret().as_bytes()),
257                &validation,
258            )
259            .map_err(|e| {
260                crate::AutumnError::unauthorized_msg(format!("JWT validation failed: {e}"))
261            })?;
262
263            // `jsonwebtoken`'s `set_audience` validates the `aud` value when
264            // the claim is *present*, but silently accepts tokens that omit the
265            // `aud` field entirely. Explicitly reject those when audience
266            // validation is enabled so legacy tokens without an `aud` claim
267            // cannot bypass the check.
268            if let Some(ref expected_aud) = config.tenancy.jwt_audience {
269                let aud_ok = token_data.claims.get("aud").is_some_and(|v| match v {
270                    serde_json::Value::String(s) => s == expected_aud,
271                    serde_json::Value::Array(arr) => arr
272                        .iter()
273                        .any(|e| e.as_str() == Some(expected_aud.as_str())),
274                    _ => false,
275                });
276                if !aud_ok {
277                    return Err(crate::AutumnError::unauthorized_msg(
278                        "JWT audience validation failed: missing or invalid aud claim",
279                    ));
280                }
281            }
282
283            let tenant = token_data
284                .claims
285                .get(&config.tenancy.jwt_claim)
286                .and_then(|v| v.as_str())
287                .ok_or_else(|| {
288                    crate::AutumnError::unauthorized_msg(format!(
289                        "Tenant claim '{}' missing from JWT payload",
290                        config.tenancy.jwt_claim
291                    ))
292                })?
293                .to_string();
294
295            if tenant.trim().is_empty() {
296                return Err(crate::AutumnError::unauthorized_msg(format!(
297                    "Tenant claim '{}' in JWT payload is empty",
298                    config.tenancy.jwt_claim
299                )));
300            }
301            Ok(tenant)
302        }
303        other => Err(crate::AutumnError::internal_server_error_msg(format!(
304            "Unsupported tenancy source: {other}"
305        ))),
306    }
307}
308
309/// Returns true when `path` is exempt from tenant resolution.
310///
311/// A path is public if:
312/// - it matches any entry in `tenancy.public_paths` (slash-boundary prefix; empty
313///   entries and trailing slashes in the list are normalized away so they can't
314///   accidentally exempt everything),
315/// - it matches the configured health/liveness/readiness/startup probe paths,
316/// - it is under the actuator prefix (e.g. `/actuator/prometheus`) — so Prometheus
317///   scraping and ops tooling are never blocked by tenancy, or
318/// - it exactly equals `tenancy.login_redirect` — so the redirect target itself is
319///   always reachable even if the operator forgot to add it to `public_paths`,
320///   preventing an infinite redirect loop.
321///
322/// The `OpenAPI`/docs endpoint is deliberately **not** auto-exempted: its mounted
323/// path comes from the programmatic `OpenApiConfig::openapi_json_path` (set via
324/// `AppBuilder::openapi(...)`), which is not visible from `AutumnConfig` here and
325/// can differ from the `[openapi]` `path` field. An app that wants its spec public
326/// under tenancy should list that path in `tenancy.public_paths`.
327///
328/// Matching uses the same slash-boundary prefix semantics as the rest of the
329/// framework (CSRF, CAPTCHA): `/login` matches `/login` and `/login/sso` but not
330/// `/login-admin`.
331fn is_public_path(path: &str, config: &crate::config::AutumnConfig) -> bool {
332    // Guard against empty prefixes: `path_matches_route_prefix(path, "")` is true
333    // for every absolute path, so an empty entry — whether a stray `public_paths`
334    // item or a misconfigured built-in like `health.path = ""` — would otherwise
335    // exempt the entire application from tenancy.
336    let matches =
337        |prefix: &str| !prefix.is_empty() && crate::router::path_matches_route_prefix(path, prefix);
338
339    // User-configured public paths — normalize trailing slashes so `/static/`
340    // behaves the same as `/static`, but preserve a bare `"/"` (a common landing
341    // page) rather than trimming it away to an empty, never-matching string.
342    let user_paths_match = config.tenancy.public_paths.iter().any(|p| {
343        let p = if p.len() > 1 {
344            p.trim_end_matches('/')
345        } else {
346            p.as_str()
347        };
348        matches(p)
349    });
350
351    // The login_redirect target must always be reachable to prevent an infinite
352    // redirect loop when a user forgets to add it to public_paths. Only auto-exempt
353    // *relative* targets (no authority): those land back on this app, so a loop is
354    // possible and the exemption is warranted. An absolute target — whether an
355    // external IdP (`https://idp.example.com/login`) or even a same-origin URL —
356    // is not auto-exempted: the external case causes no local loop, and we can't
357    // reliably tell same-origin from external here (server.host is the bind
358    // address, not the public host). A same-origin absolute target that must be
359    // public should be listed in `public_paths`. Parse as a URI so a relative
360    // target with a query (`/login?next=/dashboard`) still matches by path.
361    let redirect_match = config
362        .tenancy
363        .login_redirect
364        .as_deref()
365        .and_then(|target| target.parse::<axum::http::Uri>().ok())
366        .filter(|uri| uri.authority().is_none())
367        .is_some_and(|uri| uri.path() == path);
368
369    // Match the actuator prefix against its *normalized* form — the same
370    // transformation the actuator router applies before mounting (trim, drop
371    // trailing slashes, ensure a leading slash). Otherwise a non-canonical config
372    // value like `ops/` or `/ops/` would mount endpoints at `/ops/...` yet never
373    // match the raw string here, breaking the probe/scrape bypass.
374    let actuator_prefix = crate::actuator::normalize_actuator_prefix(&config.actuator.prefix);
375    let actuator_match = if actuator_prefix.is_empty() {
376        // A prefix that normalizes to empty (`/` or blank) is a root mount: the
377        // ops endpoints sit at `/health`, `/prometheus`, … directly. We can't
378        // exempt the whole root, so exempt the actual mounted endpoint paths.
379        crate::actuator::actuator_endpoint_paths(
380            &actuator_prefix,
381            config.actuator.sensitive,
382            config.actuator.prometheus,
383        )
384        .iter()
385        .any(|p| matches(p))
386    } else {
387        matches(&actuator_prefix)
388    };
389
390    // Built-in probe endpoints mount at their *exact* configured path
391    // (`mount_probe_endpoints` installs `router.route(&health.path, …)`, not a
392    // subtree), so match them exactly. Prefix matching here would wrongly exempt
393    // an app's own `/health/history` while the probe only serves `/health`.
394    let probe_match = path == config.health.path
395        || path == config.health.live_path
396        || path == config.health.ready_path
397        || path == config.health.startup_path;
398
399    user_paths_match || redirect_match || probe_match || actuator_match
400}
401
402// Tenancy middleware for Axum requests
403pub async fn tenancy_middleware(
404    State(state): State<crate::AppState>,
405    request: Request<axum::body::Body>,
406    next: Next,
407) -> Response {
408    let Some(config) = state.extension::<crate::config::AutumnConfig>() else {
409        return crate::AutumnError::internal_server_error_msg("AutumnConfig not found in AppState")
410            .into_response();
411    };
412
413    if !config.tenancy.enabled {
414        return next.run(request).await;
415    }
416
417    let (mut parts, body) = request.into_parts();
418
419    // Public paths (login/signup pages, static assets, health probes) stay
420    // reachable without a tenant so unauthenticated visitors can reach them —
421    // otherwise a session/jwt-sourced SaaS could never show a login screen.
422    if is_public_path(parts.uri.path(), &config) {
423        return next.run(Request::from_parts(parts, body)).await;
424    }
425
426    let tenant_id = match extract_tenant_from_parts(&mut parts, &config).await {
427        Ok(t) => t,
428        Err(e) => {
429            // For browser logins, bounce a missing/unauthenticated tenant to the
430            // configured login page instead of returning a raw 401. Only do this
431            // for clients that accept HTML (navigating browsers): API clients
432            // (e.g. `Accept: application/json`) expect the 401 so their error
433            // handling isn't broken by a 303 to a login page. Other error classes
434            // (e.g. a 500 misconfiguration) are surfaced unchanged so real bugs
435            // are not masked as login redirects.
436            if e.status() == axum::http::StatusCode::UNAUTHORIZED
437                && let Some(target) = &config.tenancy.login_redirect
438                && parts
439                    .headers
440                    .get(axum::http::header::ACCEPT)
441                    .and_then(|v| v.to_str().ok())
442                    .is_some_and(|accept| accept.contains("text/html"))
443            {
444                return axum::response::Redirect::to(target).into_response();
445            }
446            return e.into_response();
447        }
448    };
449
450    // Tag the request-scoped log context (#1169) so every subsequent event
451    // automatically carries the resolved tenant id.
452    crate::log::context::set_tenant_id(&tenant_id);
453
454    let request = Request::from_parts(parts, body);
455    let tenant_id_clone = tenant_id.clone();
456
457    // Bind a lazily-materializing handle to the tenant's memory cell for the
458    // request lifecycle. The registry itself is lazily registered in the app
459    // state's extension map on first use, but building a handle does NOT create
460    // a cell: the cell is materialized only when a handler (or a streaming body)
461    // first accesses it via `current_tenant_cell()`. Requests to routes that
462    // never touch tenant memory therefore leave the registry untouched, even
463    // with request-controlled tenant ids.
464    let registry = state.extension_or_insert_with(|| {
465        crate::tenant_cell::TenantCellRegistry::with_limits(
466            config.tenancy.max_cells,
467            (config.tenancy.idle_ttl_secs > 0)
468                .then(|| std::time::Duration::from_secs(config.tenancy.idle_ttl_secs)),
469        )
470    });
471    let handle = crate::tenant_cell::TenantCellHandle::new(
472        (*registry).clone(),
473        tenant_id.clone(),
474        config.tenancy.quota_bytes,
475    );
476    let handle_for_body = Some(handle.clone());
477
478    let response = CURRENT_TENANT
479        .scope(
480            Some(tenant_id),
481            crate::tenant_cell::CURRENT_TENANT_CELL.scope(Some(handle), next.run(request)),
482        )
483        .await;
484
485    let (parts, body) = response.into_parts();
486    let wrapped = TenantPropagatingBody {
487        inner: body,
488        tenant_id: tenant_id_clone,
489        handle: handle_for_body,
490    };
491    Response::from_parts(parts, axum::body::Body::new(wrapped))
492}
493
494pin_project! {
495    /// A response body wrapper that re-establishes the tenant context
496    /// for each poll of the inner body, so lazy/streaming bodies can
497    /// access tenant-scoped repositories during their polling phase.
498    pub struct TenantPropagatingBody<B> {
499        #[pin]
500        pub inner: B,
501        pub tenant_id: String,
502        pub handle: Option<crate::tenant_cell::TenantCellHandle>,
503    }
504}
505
506impl<B> HttpBody for TenantPropagatingBody<B>
507where
508    B: HttpBody,
509{
510    type Data = B::Data;
511    type Error = B::Error;
512
513    fn poll_frame(
514        self: Pin<&mut Self>,
515        cx: &mut Context<'_>,
516    ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
517        let this = self.project();
518        let tenant_id = this.tenant_id.clone();
519        let handle = this.handle.clone();
520        CURRENT_TENANT.sync_scope(Some(tenant_id), || {
521            crate::tenant_cell::CURRENT_TENANT_CELL.sync_scope(handle, || this.inner.poll_frame(cx))
522        })
523    }
524
525    fn is_end_stream(&self) -> bool {
526        self.inner.is_end_stream()
527    }
528
529    fn size_hint(&self) -> http_body::SizeHint {
530        self.inner.size_hint()
531    }
532}
533
534/// A trait implemented by model insertable helper types to dynamically set tenant ID.
535///
536/// This sets or appends the tenant ID before database insertion. This avoids SQL duplicate
537/// column errors when a model already has a manual (non-default) `tenant_id` field.
538#[cfg(feature = "db")]
539pub trait TenantInsertable<'a, Table> {
540    type Values;
541    fn tenant_values(self, tenant_id: &'a str) -> Self::Values;
542}
543
544/// Metadata about a model's `tenant_id` struct field.
545#[cfg(feature = "db")]
546pub trait ModelTenantIdMeta {
547    /// True if the struct has a manual `tenant_id` field.
548    const HAS_MANUAL_TENANT_ID: bool;
549    /// Sets the tenant ID field on the struct if it has one.
550    fn try_set_tenant_id(&mut self, tenant_id: &str);
551}
552
553/// A trait that bridges a Diesel table to its `tenant_id` column.
554#[cfg(feature = "db")]
555pub trait HasTenantIdColumn {
556    type Column: ::diesel::Expression;
557    fn column() -> Self::Column;
558}
559
560/// A selector helper to choose between different insertable values.
561#[cfg(feature = "db")]
562pub struct TenantInsertableValuesSelector<'a, T, Table, const HAS_MANUAL: bool> {
563    pub inner: T,
564    pub tenant_id: &'a str,
565    pub _marker: std::marker::PhantomData<Table>,
566}
567
568/// A trait implemented by selector variants to get the actual insertable values.
569#[cfg(feature = "db")]
570pub trait GetInsertableValues {
571    type Values;
572    fn get_values(self) -> Self::Values;
573}
574
575#[cfg(feature = "db")]
576impl<T, Table> GetInsertableValues for TenantInsertableValuesSelector<'_, T, Table, true>
577where
578    T: ModelTenantIdMeta,
579{
580    type Values = T;
581    fn get_values(mut self) -> Self::Values {
582        self.inner.try_set_tenant_id(self.tenant_id);
583        self.inner
584    }
585}
586
587#[cfg(feature = "db")]
588impl<'a, T, Table> GetInsertableValues for TenantInsertableValuesSelector<'a, T, Table, false>
589where
590    Table: HasTenantIdColumn,
591    Table::Column: ::diesel::ExpressionMethods,
592    <Table::Column as ::diesel::Expression>::SqlType: ::diesel::sql_types::SqlType,
593    &'a str: ::diesel::expression::AsExpression<<Table::Column as ::diesel::Expression>::SqlType>,
594{
595    type Values = (T, ::diesel::dsl::Eq<Table::Column, &'a str>);
596    fn get_values(self) -> Self::Values {
597        use ::diesel::ExpressionMethods;
598        (self.inner, Table::column().eq(self.tenant_id))
599    }
600}
601
602/// Helper trait to display or extract a string representation of the tenant ID.
603pub trait DisplayTenantId {
604    /// Returns the string slice of the tenant ID.
605    fn tenant_id_str(&self) -> &str;
606}
607
608impl DisplayTenantId for String {
609    fn tenant_id_str(&self) -> &str {
610        self
611    }
612}
613
614impl DisplayTenantId for Option<String> {
615    fn tenant_id_str(&self) -> &str {
616        self.as_deref().unwrap_or("default")
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::security::ResolvedClientIdentity;
624
625    fn subdomain_config() -> crate::config::AutumnConfig {
626        let mut c = crate::config::AutumnConfig::default();
627        c.tenancy.enabled = true;
628        c.tenancy.source = "subdomain".to_string();
629        c
630    }
631
632    fn subdomain_config_with_base(base: &str) -> crate::config::AutumnConfig {
633        let mut c = subdomain_config();
634        c.tenancy.base_domain = Some(base.to_string());
635        c
636    }
637
638    fn make_parts(host: &str) -> axum::http::request::Parts {
639        let (parts, ()) = axum::http::Request::builder()
640            .uri("http://ignored/")
641            .header(axum::http::header::HOST, host)
642            .body(())
643            .unwrap()
644            .into_parts();
645        parts
646    }
647
648    fn make_parts_with_identity(
649        host_header: &str,
650        resolved_host: &str,
651    ) -> axum::http::request::Parts {
652        let (mut parts, ()) = axum::http::Request::builder()
653            .uri("http://ignored/")
654            .header(axum::http::header::HOST, host_header)
655            .body(())
656            .unwrap()
657            .into_parts();
658        parts.extensions.insert(ResolvedClientIdentity {
659            addr: None,
660            host: Some(resolved_host.to_string()),
661            scheme: None,
662        });
663        parts
664    }
665
666    /// When no `ResolvedClientIdentity` extension is present, subdomain mode falls
667    /// back to the raw Host header as before.
668    #[tokio::test]
669    async fn subdomain_falls_back_to_host_header_without_extension() {
670        let config = subdomain_config();
671        let mut parts = make_parts("tenant1.example.com");
672        let result = extract_tenant_from_parts(&mut parts, &config).await;
673        assert_eq!(result.unwrap(), "tenant1");
674    }
675
676    /// When `ResolvedClientIdentity.host` is present, subdomain mode uses it instead
677    /// of the raw Host header so that X-Forwarded-Host from trusted proxies is honoured.
678    #[tokio::test]
679    async fn subdomain_uses_resolved_host_from_extension() {
680        let config = subdomain_config();
681        // Raw Host header is the internal address; resolved host is the public subdomain.
682        let mut parts = make_parts_with_identity("internal.cluster.local", "tenant1.example.com");
683        let result = extract_tenant_from_parts(&mut parts, &config).await;
684        assert_eq!(result.unwrap(), "tenant1");
685    }
686
687    /// With a configured `base_domain`, the resolved host is matched against it.
688    #[tokio::test]
689    async fn subdomain_uses_resolved_host_with_base_domain() {
690        let config = subdomain_config_with_base("example.com");
691        let mut parts = make_parts_with_identity("internal.cluster.local", "acme.example.com");
692        let result = extract_tenant_from_parts(&mut parts, &config).await;
693        assert_eq!(result.unwrap(), "acme");
694    }
695
696    /// Port suffixes in the resolved host are stripped before subdomain extraction.
697    #[tokio::test]
698    async fn subdomain_strips_port_from_resolved_host() {
699        let config = subdomain_config_with_base("example.com");
700        let mut parts =
701            make_parts_with_identity("internal.cluster.local", "tenant2.example.com:8080");
702        let result = extract_tenant_from_parts(&mut parts, &config).await;
703        assert_eq!(result.unwrap(), "tenant2");
704    }
705
706    /// When `ResolvedClientIdentity.host` is `None` (layer ran but found no host),
707    /// subdomain mode falls back to the raw Host header.
708    #[tokio::test]
709    async fn subdomain_falls_back_when_resolved_host_is_none() {
710        let config = subdomain_config();
711        let (mut parts, ()) = axum::http::Request::builder()
712            .uri("http://ignored/")
713            .header(axum::http::header::HOST, "tenant3.example.com")
714            .body(())
715            .unwrap()
716            .into_parts();
717        parts.extensions.insert(ResolvedClientIdentity {
718            addr: None,
719            host: None,
720            scheme: None,
721        });
722        let result = extract_tenant_from_parts(&mut parts, &config).await;
723        assert_eq!(result.unwrap(), "tenant3");
724    }
725
726    fn public_paths_config(paths: &[&str]) -> crate::config::AutumnConfig {
727        let mut c = crate::config::AutumnConfig::default();
728        c.tenancy.public_paths = paths.iter().map(|s| (*s).to_string()).collect();
729        c
730    }
731
732    /// A configured public path matches itself and any slash-delimited subpath.
733    #[test]
734    fn public_path_exact_and_subtree_match() {
735        let c = public_paths_config(&["/login", "/static"]);
736        assert!(is_public_path("/login", &c));
737        assert!(is_public_path("/login/sso", &c));
738        assert!(is_public_path("/static/css/app.css", &c));
739    }
740
741    /// Exemptions do not bleed into adjacent prefixes or unrelated routes.
742    #[test]
743    fn public_path_does_not_bleed_to_adjacent_prefix() {
744        let c = public_paths_config(&["/login"]);
745        assert!(!is_public_path("/login-admin", &c));
746        assert!(!is_public_path("/dashboard", &c));
747    }
748
749    /// Health/liveness/readiness/startup probes are public without being listed.
750    #[test]
751    fn health_paths_are_always_public() {
752        let c = crate::config::AutumnConfig::default();
753        assert!(c.tenancy.public_paths.is_empty());
754        assert!(is_public_path(&c.health.path, &c));
755        assert!(is_public_path(&c.health.live_path, &c));
756        assert!(is_public_path(&c.health.ready_path, &c));
757        assert!(is_public_path(&c.health.startup_path, &c));
758    }
759
760    /// Probe exemptions match the configured path *exactly* (the probe mounts at
761    /// that exact path), so an app's own subroute like `/health/history` is still
762    /// tenant-scoped rather than wrongly treated as public.
763    #[test]
764    fn probe_paths_match_exactly_not_as_prefix() {
765        let c = crate::config::AutumnConfig::default();
766        assert!(is_public_path("/health", &c));
767        assert!(!is_public_path("/health/history", &c));
768        assert!(!is_public_path("/live/details", &c));
769    }
770
771    /// Empty-string entries in `public_paths` must not exempt every path.
772    #[test]
773    fn empty_public_path_entry_does_not_exempt_all() {
774        let c = public_paths_config(&[""]);
775        assert!(!is_public_path("/dashboard", &c));
776        assert!(!is_public_path("/secret", &c));
777    }
778
779    /// Trailing-slash entries in `public_paths` behave like their unslashed form.
780    #[test]
781    fn trailing_slash_entry_matches_subtree() {
782        let c = public_paths_config(&["/static/"]);
783        assert!(is_public_path("/static", &c));
784        assert!(is_public_path("/static/css/app.css", &c));
785        assert!(!is_public_path("/dashboard", &c));
786    }
787
788    /// The actuator prefix is always public so Prometheus scraping is never blocked.
789    #[test]
790    fn actuator_prefix_is_always_public() {
791        let c = crate::config::AutumnConfig::default();
792        assert!(is_public_path(&c.actuator.prefix, &c));
793        assert!(is_public_path(
794            &format!("{}/prometheus", c.actuator.prefix),
795            &c
796        ));
797        assert!(is_public_path(&format!("{}/health", c.actuator.prefix), &c));
798    }
799
800    /// A non-canonical actuator prefix (no leading slash, trailing slash) is
801    /// exempted at its *mounted* (normalized) path, matching the actuator router.
802    #[test]
803    fn actuator_prefix_is_normalized_before_exemption() {
804        for raw in ["ops/", "/ops/", "/ops", "ops"] {
805            let mut c = crate::config::AutumnConfig::default();
806            c.actuator.prefix = raw.to_string();
807            assert!(
808                is_public_path("/ops/prometheus", &c),
809                "prefix {raw:?} should exempt the mounted /ops/prometheus path"
810            );
811            assert!(
812                is_public_path("/ops", &c),
813                "prefix {raw:?} should exempt the mounted /ops base path"
814            );
815            assert!(!is_public_path("/dashboard", &c));
816        }
817    }
818
819    /// A root-mounted actuator (`prefix = "/"`, normalizing to empty) exempts its
820    /// actual endpoint paths rather than failing the empty-prefix guard.
821    #[test]
822    fn root_mounted_actuator_endpoints_are_public() {
823        let mut c = crate::config::AutumnConfig::default();
824        c.actuator.prefix = "/".to_string();
825        c.actuator.prometheus = true;
826        // Prometheus + metrics live at root here.
827        assert!(is_public_path("/prometheus", &c));
828        assert!(is_public_path("/metrics", &c));
829        // But the root itself does not exempt the whole app.
830        assert!(!is_public_path("/dashboard", &c));
831    }
832
833    /// The `OpenAPI`/docs spec path is NOT auto-exempted: its real mounted path
834    /// lives in the programmatic `OpenApiConfig`, not `AutumnConfig`, so apps that
835    /// want it public under tenancy must list it in `public_paths`.
836    #[test]
837    fn openapi_path_is_not_auto_exempt() {
838        let c = crate::config::AutumnConfig::default();
839        assert!(c.openapi_runtime.enabled);
840        assert!(!is_public_path(&c.openapi_runtime.path, &c));
841        // It becomes public only when explicitly listed.
842        let c = public_paths_config(&["/openapi.json"]);
843        assert!(is_public_path("/openapi.json", &c));
844    }
845
846    /// The `login_redirect` target is always public even if missing from
847    /// `public_paths`, preventing an infinite redirect loop.
848    #[test]
849    fn login_redirect_target_is_always_public() {
850        let mut c = crate::config::AutumnConfig::default();
851        c.tenancy.login_redirect = Some("/auth/login".to_string());
852        // Not in public_paths — should still be reachable.
853        assert!(is_public_path("/auth/login", &c));
854        // Only the exact target, not adjacent paths.
855        assert!(!is_public_path("/auth/login/sso", &c));
856    }
857
858    /// A `login_redirect` target carrying a query string is matched by its path
859    /// component, so the login page is still exempted and no loop forms.
860    #[test]
861    fn login_redirect_target_with_query_is_public_by_path() {
862        let mut c = crate::config::AutumnConfig::default();
863        c.tenancy.login_redirect = Some("/login?next=/dashboard".to_string());
864        // The follow-up request arrives as just the path.
865        assert!(is_public_path("/login", &c));
866        assert!(!is_public_path("/dashboard", &c));
867    }
868
869    /// An absolute-URL `login_redirect` target (external `IdP`, or even same-origin)
870    /// is NOT auto-exempted: the local path is only made public for relative
871    /// targets, since we can't validate the authority here. Same-origin absolute
872    /// targets that must be public belong in `public_paths`.
873    #[test]
874    fn login_redirect_absolute_url_is_not_auto_exempt() {
875        let mut c = crate::config::AutumnConfig::default();
876        c.tenancy.login_redirect = Some("https://idp.example.com/login".to_string());
877        assert!(!is_public_path("/login", &c));
878
879        // Same-origin absolute is likewise not auto-exempted (authority present).
880        c.tenancy.login_redirect = Some("https://app.example.com/login".to_string());
881        assert!(!is_public_path("/login", &c));
882
883        // Listing it explicitly still works.
884        c.tenancy.public_paths = vec!["/login".to_string()];
885        assert!(is_public_path("/login", &c));
886    }
887
888    /// A bare `"/"` in `public_paths` exempts the root exactly (a common landing
889    /// page) and is not silently trimmed away to an empty, never-matching entry.
890    #[test]
891    fn root_public_path_is_preserved() {
892        let c = public_paths_config(&["/"]);
893        assert!(is_public_path("/", &c));
894        // The slash-boundary semantics mean `/` matches only the root, not every
895        // path, so protected routes still require a tenant.
896        assert!(!is_public_path("/dashboard", &c));
897    }
898
899    /// A misconfigured empty built-in path (e.g. `health.path = ""`) must not
900    /// exempt the whole application from tenancy.
901    #[test]
902    fn empty_builtin_path_does_not_exempt_all() {
903        let mut c = crate::config::AutumnConfig::default();
904        c.health.path = String::new();
905        c.health.live_path = String::new();
906        c.health.ready_path = String::new();
907        c.health.startup_path = String::new();
908        c.actuator.prefix = String::new();
909        assert!(!is_public_path("/dashboard", &c));
910        assert!(!is_public_path("/", &c));
911    }
912}