Skip to main content

greentic_runner_host/
routing.rs

1use std::sync::Arc;
2
3use anyhow::{Result, anyhow, bail};
4use axum::extract::{FromRef, FromRequestParts};
5use axum::http::header::{AUTHORIZATION, HOST};
6use axum::http::request::Parts;
7use axum::http::{HeaderName, StatusCode};
8use base64::Engine;
9use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD};
10use serde_json::Value;
11use serde_json::json;
12
13use crate::runner::ServerState;
14use crate::runtime::TenantRuntime;
15
16/// Tenant a request resolves to when nothing else names one.
17///
18/// Must equal `greentic_types::DEFAULT_TENANT`. It is duplicated rather than
19/// imported because this crate is transitively pinned to greentic-types
20/// `=1.1.2` by `greentic-ext-runtime =1.2.24`, so the floor cannot move until
21/// that is republished. `routing_defaults_to_the_fleet_tenant_not_demo` guards
22/// the copy; delete it in favour of the import once the pin is free.
23///
24/// Was `demo`, which disagreed with greentic-deployer and greentic-setup — the
25/// host served requests in one namespace and read secrets from another.
26pub const DEFAULT_TENANT: &str = "default";
27
28#[derive(Clone)]
29pub struct RoutingConfig {
30    pub resolver: TenantResolver,
31    pub default_tenant: String,
32}
33
34impl RoutingConfig {
35    pub fn from_env() -> Self {
36        Self::from_env_with_default(DEFAULT_TENANT.into())
37    }
38
39    pub fn from_env_with_default(default_tenant: String) -> Self {
40        let default_tenant = std::env::var("DEFAULT_TENANT").unwrap_or(default_tenant);
41        let resolver = std::env::var("TENANT_RESOLVER")
42            .map(|value| TenantResolver::from_str(&value, &default_tenant))
43            .unwrap_or(Ok(TenantResolver::Env))
44            .unwrap_or_else(|err| {
45                tracing::warn!(error = %err, "invalid TENANT_RESOLVER, falling back to env");
46                TenantResolver::Env
47            });
48        Self {
49            resolver,
50            default_tenant,
51        }
52    }
53}
54
55impl Default for RoutingConfig {
56    fn default() -> Self {
57        Self {
58            resolver: TenantResolver::Env,
59            default_tenant: DEFAULT_TENANT.into(),
60        }
61    }
62}
63
64#[derive(Clone)]
65pub enum TenantResolver {
66    Host,
67    Header(HeaderName),
68    Jwt { header: HeaderName, claim: String },
69    Env,
70}
71
72impl TenantResolver {
73    fn from_str(value: &str, _default: &str) -> Result<Self> {
74        match value.to_ascii_lowercase().as_str() {
75            "host" => Ok(Self::Host),
76            "header" => Ok(Self::Header(HeaderName::from_static("x-greentic-tenant"))),
77            "jwt" => Ok(Self::Jwt {
78                header: AUTHORIZATION,
79                claim: "tenant".into(),
80            }),
81            "env" => Ok(Self::Env),
82            other => bail!("unsupported TENANT_RESOLVER `{other}`"),
83        }
84    }
85}
86
87#[derive(Clone)]
88pub struct TenantRouting {
89    resolver: TenantResolver,
90    default_tenant: String,
91}
92
93impl TenantRouting {
94    pub fn new(cfg: RoutingConfig) -> Self {
95        Self {
96            resolver: cfg.resolver,
97            default_tenant: cfg.default_tenant,
98        }
99    }
100
101    /// Return the configured default tenant identifier.
102    pub fn default_tenant(&self) -> &str {
103        &self.default_tenant
104    }
105
106    pub fn resolve(&self, parts: &Parts) -> Result<String> {
107        match &self.resolver {
108            TenantResolver::Env => Ok(self.default_tenant.clone()),
109            TenantResolver::Host => {
110                let host = parts
111                    .headers
112                    .get(HOST)
113                    .and_then(|value| value.to_str().ok())
114                    .unwrap_or_default();
115                if host.is_empty() {
116                    return Ok(self.default_tenant.clone());
117                }
118                Ok(host
119                    .split('.')
120                    .next()
121                    .map(|segment| segment.to_string())
122                    .filter(|segment| !segment.is_empty())
123                    .unwrap_or_else(|| self.default_tenant.clone()))
124            }
125            TenantResolver::Header(name) => {
126                let tenant = parts
127                    .headers
128                    .get(name)
129                    .and_then(|value| value.to_str().ok())
130                    .filter(|value| !value.is_empty())
131                    .map(|value| value.to_string())
132                    .unwrap_or_else(|| self.default_tenant.clone());
133                Ok(tenant)
134            }
135            TenantResolver::Jwt { header, claim } => {
136                let token = parts
137                    .headers
138                    .get(header)
139                    .and_then(|value| value.to_str().ok())
140                    .and_then(|value| value.strip_prefix("Bearer "))
141                    .ok_or_else(|| anyhow!("authorization header missing"))?;
142                let tenant = decode_jwt_claim(token, claim)
143                    .unwrap_or_else(|err| {
144                        tracing::warn!(error = %err, "failed to decode jwt claim");
145                        None
146                    })
147                    .unwrap_or_else(|| self.default_tenant.clone());
148                Ok(tenant)
149            }
150        }
151    }
152}
153
154fn decode_jwt_claim(token: &str, claim: &str) -> Result<Option<String>> {
155    let payload = token
156        .split('.')
157        .nth(1)
158        .ok_or_else(|| anyhow!("invalid jwt structure"))?;
159    let bytes = URL_SAFE_NO_PAD.decode(payload.as_bytes()).or_else(|_| {
160        let padded = match payload.len() % 4 {
161            2 => Some(format!("{payload}==")),
162            3 => Some(format!("{payload}=")),
163            _ => None,
164        };
165        if let Some(padded) = padded.as_deref() {
166            URL_SAFE
167                .decode(padded.as_bytes())
168                .or_else(|_| STANDARD.decode(padded.as_bytes()))
169        } else {
170            URL_SAFE
171                .decode(payload.as_bytes())
172                .or_else(|_| STANDARD.decode(payload.as_bytes()))
173        }
174    })?;
175    let value: Value = serde_json::from_slice(&bytes)?;
176    Ok(value
177        .get(claim)
178        .and_then(|node| node.as_str())
179        .map(|value| value.to_string()))
180}
181
182pub struct TenantRuntimeHandle {
183    pub tenant: String,
184    pub runtime: Arc<TenantRuntime>,
185}
186
187impl<S> FromRequestParts<S> for TenantRuntimeHandle
188where
189    ServerState: FromRef<S>,
190    S: Send + Sync,
191{
192    type Rejection = (StatusCode, axum::Json<Value>);
193
194    fn from_request_parts(
195        parts: &mut Parts,
196        state: &S,
197    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
198        let server_state = ServerState::from_ref(state);
199        async move {
200            let tenant = server_state.routing.resolve(parts).map_err(|err| {
201                (
202                    StatusCode::BAD_REQUEST,
203                    axum::Json(json!({ "error": err.to_string() })),
204                )
205            })?;
206            let runtime = server_state.active.load_pack(&tenant).ok_or_else(|| {
207                (
208                    StatusCode::NOT_FOUND,
209                    axum::Json(json!({ "error": "tenant not loaded" })),
210                )
211            })?;
212            Ok(Self { tenant, runtime })
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use axum::http::Request;
221
222    #[test]
223    fn host_resolver_picks_subdomain() {
224        let routing = TenantRouting::new(RoutingConfig {
225            resolver: TenantResolver::Host,
226            default_tenant: "demo".into(),
227        });
228        let (parts, _) = Request::builder()
229            .uri("http://foo.example.com/webhook")
230            .header(HOST, "foo.example.com")
231            .body(())
232            .unwrap()
233            .into_parts();
234        let tenant = routing.resolve(&parts).unwrap();
235        assert_eq!(tenant, "foo");
236    }
237
238    #[test]
239    fn header_resolver_defaults() {
240        let routing = TenantRouting::new(RoutingConfig {
241            resolver: TenantResolver::Header(HeaderName::from_static("x-tenant")),
242            default_tenant: "demo".into(),
243        });
244        let (parts, _) = Request::builder()
245            .uri("http://localhost")
246            .body(())
247            .unwrap()
248            .into_parts();
249        let tenant = routing.resolve(&parts).unwrap();
250        assert_eq!(tenant, "demo");
251    }
252
253    /// The tenant a request resolves to when nothing else names one. It was
254    /// `demo` while greentic-deployer and greentic-setup bound deployments
255    /// under `default`, so the host served requests in one namespace and read
256    /// secrets from another.
257    #[test]
258    fn routing_defaults_to_the_fleet_tenant_not_demo() {
259        assert_eq!(RoutingConfig::default().default_tenant, DEFAULT_TENANT);
260        assert_ne!(RoutingConfig::default().default_tenant, "demo");
261        // Pin the literal too: this is a copy of greentic_types::DEFAULT_TENANT
262        // that exists only because the greentic-types floor is pinned.
263        assert_eq!(DEFAULT_TENANT, "default");
264    }
265
266    #[test]
267    fn from_env_with_default_uses_override() {
268        let expected = std::env::var("DEFAULT_TENANT").unwrap_or_else(|_| "custom".into());
269        let cfg = RoutingConfig::from_env_with_default("custom".into());
270        assert_eq!(cfg.default_tenant, expected);
271    }
272
273    #[test]
274    fn jwt_resolver_reads_tenant_claim() {
275        let routing = TenantRouting::new(RoutingConfig {
276            resolver: TenantResolver::Jwt {
277                header: AUTHORIZATION,
278                claim: "tenant".into(),
279            },
280            default_tenant: "demo".into(),
281        });
282        let payload = STANDARD.encode(br#"{"tenant":"jwt-tenant"}"#);
283        let token = format!("ignored.{payload}.ignored");
284        let (parts, _) = Request::builder()
285            .header(AUTHORIZATION, format!("Bearer {token}"))
286            .body(())
287            .unwrap()
288            .into_parts();
289
290        assert_eq!(routing.resolve(&parts).unwrap(), "jwt-tenant");
291    }
292
293    #[test]
294    fn jwt_resolver_falls_back_on_invalid_payload() {
295        let routing = TenantRouting::new(RoutingConfig {
296            resolver: TenantResolver::Jwt {
297                header: AUTHORIZATION,
298                claim: "tenant".into(),
299            },
300            default_tenant: "demo".into(),
301        });
302        let (parts, _) = Request::builder()
303            .header(AUTHORIZATION, "Bearer invalid.token.payload")
304            .body(())
305            .unwrap()
306            .into_parts();
307
308        assert_eq!(routing.resolve(&parts).unwrap(), "demo");
309    }
310
311    #[test]
312    fn jwt_resolver_requires_bearer_prefix() {
313        let routing = TenantRouting::new(RoutingConfig {
314            resolver: TenantResolver::Jwt {
315                header: AUTHORIZATION,
316                claim: "tenant".into(),
317            },
318            default_tenant: "demo".into(),
319        });
320        let (parts, _) = Request::builder().body(()).unwrap().into_parts();
321
322        assert!(routing.resolve(&parts).is_err());
323    }
324}