Skip to main content

structured_proxy/
config.rs

1//! YAML-based proxy configuration.
2//!
3//! All product-specific behavior is driven by config, not code.
4//! Same binary, different YAML = different product proxy.
5
6use serde::Deserialize;
7use std::path::PathBuf;
8
9/// Top-level proxy configuration (loaded from YAML).
10///
11/// This and the wiring structs below (`UpstreamConfig`, `ListenConfig`,
12/// `ServiceConfig`, `DescriptorSource`) are intentionally NOT
13/// `#[non_exhaustive]`: embedding consumers build them programmatically with
14/// runtime values. The leaf auth/shield/oidc config structs are
15/// `#[non_exhaustive]` instead, since those are deserialized, not hand-built.
16#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18    /// Upstream gRPC service(s).
19    pub upstream: UpstreamConfig,
20
21    /// Proto descriptor sources.
22    #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
23    pub descriptors: Vec<DescriptorSource>,
24
25    /// Listen addresses.
26    #[serde(default)]
27    pub listen: ListenConfig,
28
29    /// Service identity (for health endpoint, metrics namespace).
30    #[serde(default)]
31    pub service: ServiceConfig,
32
33    /// Path aliases (e.g., /oauth2/* → /v1/oauth2/*).
34    #[serde(default)]
35    pub aliases: Vec<AliasConfig>,
36
37    /// OpenAPI generation.
38    #[serde(default)]
39    pub openapi: Option<OpenApiConfig>,
40
41    /// Auth configuration (JWT, forward auth, AuthZ).
42    #[serde(default)]
43    pub auth: Option<AuthConfig>,
44
45    /// Rate limiting (Shield).
46    #[serde(default)]
47    pub shield: Option<ShieldConfig>,
48
49    /// OIDC discovery (optional — for IdP proxies).
50    #[serde(default)]
51    pub oidc_discovery: Option<OidcDiscoveryConfig>,
52
53    /// Maintenance mode.
54    #[serde(default)]
55    pub maintenance: MaintenanceConfig,
56
57    /// CORS configuration.
58    #[serde(default)]
59    pub cors: CorsConfig,
60
61    /// Logging.
62    #[serde(default)]
63    pub logging: LoggingConfig,
64
65    /// Metrics endpoint classification (path patterns → class labels).
66    #[serde(default)]
67    pub metrics_classes: Vec<MetricsClassConfig>,
68
69    /// Headers to forward from HTTP to gRPC metadata.
70    #[serde(default = "default_forwarded_headers")]
71    pub forwarded_headers: Vec<String>,
72}
73
74fn default_forwarded_headers() -> Vec<String> {
75    vec![
76        "authorization".into(),
77        "dpop".into(),
78        "x-request-id".into(),
79        "x-forwarded-for".into(),
80        "x-forwarded-proto".into(),
81        "x-real-ip".into(),
82        "accept-language".into(),
83        "user-agent".into(),
84        "idempotency-key".into(),
85    ]
86}
87
88/// Upstream gRPC service configuration.
89#[derive(Debug, Clone, Deserialize)]
90pub struct UpstreamConfig {
91    /// gRPC upstream address (e.g., "http://localhost:4180").
92    pub default: String,
93}
94
95/// Descriptor loading source.
96#[derive(Debug, Clone)]
97pub enum DescriptorSource {
98    /// Pre-compiled descriptor file.
99    File { file: PathBuf },
100    /// gRPC server reflection (development mode).
101    Reflection { reflection: String },
102    /// Embedded bytes (set programmatically, not from YAML).
103    Embedded { bytes: &'static [u8] },
104}
105
106/// Helper for YAML deserialization (only File and Reflection variants).
107#[derive(Debug, Clone, Deserialize)]
108#[serde(untagged)]
109enum DescriptorSourceYaml {
110    File { file: PathBuf },
111    Reflection { reflection: String },
112}
113
114impl From<DescriptorSourceYaml> for DescriptorSource {
115    fn from(yaml: DescriptorSourceYaml) -> Self {
116        match yaml {
117            DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
118            DescriptorSourceYaml::Reflection { reflection } => {
119                DescriptorSource::Reflection { reflection }
120            }
121        }
122    }
123}
124
125fn deserialize_descriptor_sources<'de, D>(
126    deserializer: D,
127) -> std::result::Result<Vec<DescriptorSource>, D::Error>
128where
129    D: serde::Deserializer<'de>,
130{
131    let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
132    Ok(yaml_sources.into_iter().map(Into::into).collect())
133}
134
135/// Listen address configuration.
136#[derive(Debug, Clone, Deserialize)]
137pub struct ListenConfig {
138    /// HTTP listen address (default: "0.0.0.0:8080").
139    #[serde(default = "default_http_listen")]
140    pub http: String,
141}
142
143fn default_http_listen() -> String {
144    "0.0.0.0:8080".into()
145}
146
147impl Default for ListenConfig {
148    fn default() -> Self {
149        Self {
150            http: default_http_listen(),
151        }
152    }
153}
154
155/// Service identity.
156#[derive(Debug, Clone, Deserialize)]
157pub struct ServiceConfig {
158    /// Service name (appears in /health response and metrics namespace).
159    #[serde(default = "default_service_name")]
160    pub name: String,
161}
162
163fn default_service_name() -> String {
164    "structured-proxy".into()
165}
166
167impl Default for ServiceConfig {
168    fn default() -> Self {
169        Self {
170            name: default_service_name(),
171        }
172    }
173}
174
175/// Path alias (rewrite before routing).
176#[derive(Debug, Clone, Deserialize)]
177#[non_exhaustive]
178pub struct AliasConfig {
179    pub from: String,
180    pub to: String,
181}
182
183/// OpenAPI generation config.
184#[derive(Debug, Clone, Deserialize)]
185#[non_exhaustive]
186pub struct OpenApiConfig {
187    #[serde(default = "default_true")]
188    pub enabled: bool,
189    /// Path for OpenAPI JSON spec (default: "/openapi.json").
190    #[serde(default = "default_openapi_path")]
191    pub path: String,
192    /// Path for interactive API docs UI (default: "/docs").
193    #[serde(default = "default_docs_path")]
194    pub docs_path: String,
195    #[serde(default)]
196    pub title: Option<String>,
197    #[serde(default)]
198    pub version: Option<String>,
199}
200
201fn default_openapi_path() -> String {
202    "/openapi.json".into()
203}
204
205fn default_docs_path() -> String {
206    "/docs".into()
207}
208
209fn default_true() -> bool {
210    true
211}
212
213/// Auth configuration.
214#[derive(Debug, Clone, Deserialize)]
215#[non_exhaustive]
216pub struct AuthConfig {
217    /// Auth mode: "none", "jwt", "api_key".
218    #[serde(default = "default_auth_mode")]
219    pub mode: String,
220
221    /// JWT validation config.
222    #[serde(default)]
223    pub jwt: Option<JwtConfig>,
224
225    /// Forward auth endpoint.
226    #[serde(default)]
227    pub forward_auth: Option<ForwardAuthConfig>,
228
229    /// AuthZ integration (optional gRPC call).
230    #[serde(default)]
231    pub authz: Option<AuthzConfig>,
232}
233
234fn default_auth_mode() -> String {
235    "none".into()
236}
237
238/// JWT validation config.
239#[derive(Debug, Clone, Deserialize)]
240#[non_exhaustive]
241pub struct JwtConfig {
242    /// JWKS URI for key discovery.
243    #[serde(default)]
244    pub jwks_uri: Option<String>,
245    /// Expected issuer.
246    #[serde(default)]
247    pub issuer: Option<String>,
248    /// Expected audience.
249    #[serde(default)]
250    pub audience: Option<String>,
251    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
252    #[serde(default)]
253    pub public_key_pem_file: Option<PathBuf>,
254    /// Claims → HTTP headers mapping.
255    #[serde(default)]
256    pub claims_headers: std::collections::HashMap<String, String>,
257    /// Claim holding the user's roles (array of strings). Supports a dotted
258    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
259    #[serde(default = "default_roles_claim")]
260    pub roles_claim: String,
261}
262
263fn default_roles_claim() -> String {
264    "roles".into()
265}
266
267/// Forward auth config.
268#[derive(Debug, Clone, Deserialize)]
269#[non_exhaustive]
270pub struct ForwardAuthConfig {
271    #[serde(default)]
272    pub enabled: bool,
273    #[serde(default = "default_forward_auth_path")]
274    pub path: String,
275    /// Route policies.
276    #[serde(default)]
277    pub policies: Vec<RoutePolicyConfig>,
278    /// Login URL for 401 redirects.
279    #[serde(default)]
280    pub login_url: Option<String>,
281    /// Applications YAML file path.
282    #[serde(default)]
283    pub applications_path: Option<PathBuf>,
284}
285
286fn default_forward_auth_path() -> String {
287    "/auth/verify".into()
288}
289
290/// Route policy entry.
291#[derive(Debug, Clone, Deserialize)]
292#[non_exhaustive]
293pub struct RoutePolicyConfig {
294    pub path: String,
295    #[serde(default = "default_methods_all")]
296    pub methods: Vec<String>,
297    #[serde(default)]
298    pub require_auth: bool,
299    #[serde(default)]
300    pub required_roles: Vec<String>,
301}
302
303fn default_methods_all() -> Vec<String> {
304    vec!["*".into()]
305}
306
307/// External authorization via the Envoy ext_authz gRPC contract
308/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
309/// ext_authz server.
310#[derive(Debug, Clone, Deserialize)]
311#[non_exhaustive]
312pub struct AuthzConfig {
313    /// Enable external authorization for proxied API requests.
314    #[serde(default)]
315    pub enabled: bool,
316    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
317    /// when enabled; defaults to empty so a disabled block can omit it.
318    #[serde(default)]
319    pub endpoint: String,
320    /// Per-request authorization call timeout, in milliseconds.
321    #[serde(default = "default_authz_timeout_ms")]
322    pub timeout_ms: u64,
323    /// When the authz call itself fails (unreachable / timeout), allow the
324    /// request through instead of denying. Defaults to false (fail closed).
325    #[serde(default)]
326    pub failure_mode_allow: bool,
327}
328
329fn default_authz_timeout_ms() -> u64 {
330    200
331}
332
333/// Shield (rate limiting) configuration.
334#[derive(Debug, Clone, Deserialize)]
335#[non_exhaustive]
336pub struct ShieldConfig {
337    #[serde(default)]
338    pub enabled: bool,
339    /// Endpoint classification (glob pattern → class → rate limit).
340    #[serde(default)]
341    pub endpoint_classes: Vec<EndpointClassConfig>,
342    /// Per-identifier rate limiting.
343    #[serde(default)]
344    pub identifier_endpoints: Vec<IdentifierEndpointConfig>,
345    /// Window size in seconds (default: 60).
346    #[serde(default = "default_window_secs")]
347    pub window_secs: u64,
348    /// Redis URL for shared counters across replicas (e.g. "redis://127.0.0.1/").
349    /// When unset, an in-process per-replica store is used. Requires the `redis`
350    /// build feature; otherwise the proxy logs a warning and uses the in-process
351    /// store.
352    #[serde(default)]
353    pub redis_url: Option<String>,
354    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
355    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
356    /// direct peer falls in one of these ranges; otherwise the peer socket
357    /// address is used as the client identity. Empty (the default) means do not
358    /// trust forwarding headers — set this behind a load balancer.
359    #[serde(default)]
360    pub trusted_proxies: Vec<String>,
361}
362
363fn default_window_secs() -> u64 {
364    60
365}
366
367/// Endpoint classification for rate limiting.
368#[derive(Debug, Clone, Deserialize)]
369#[non_exhaustive]
370pub struct EndpointClassConfig {
371    /// Glob pattern (e.g., "/v1/auth/**").
372    pub pattern: String,
373    /// Class name (e.g., "auth").
374    pub class: String,
375    /// Rate limit string (e.g., "20/min").
376    pub rate: String,
377}
378
379/// Per-identifier rate limiting config.
380#[derive(Debug, Clone, Deserialize)]
381#[non_exhaustive]
382pub struct IdentifierEndpointConfig {
383    pub path: String,
384    pub body_field: String,
385    pub rate: String,
386}
387
388/// OIDC discovery config.
389#[derive(Debug, Clone, Deserialize)]
390#[non_exhaustive]
391pub struct OidcDiscoveryConfig {
392    #[serde(default)]
393    pub enabled: bool,
394    pub issuer: String,
395    #[serde(default)]
396    pub authorization_endpoint: Option<String>,
397    #[serde(default)]
398    pub token_endpoint: Option<String>,
399    #[serde(default)]
400    pub userinfo_endpoint: Option<String>,
401    #[serde(default)]
402    pub jwks_uri: Option<String>,
403    #[serde(default)]
404    pub signing_key: Option<SigningKeyConfig>,
405}
406
407/// Signing key config for JWKS endpoint.
408#[derive(Debug, Clone, Deserialize)]
409#[non_exhaustive]
410pub struct SigningKeyConfig {
411    #[serde(default = "default_algorithm")]
412    pub algorithm: String,
413    pub public_key_pem_file: PathBuf,
414}
415
416fn default_algorithm() -> String {
417    "EdDSA".into()
418}
419
420/// Maintenance mode config.
421#[derive(Debug, Clone, Deserialize)]
422#[non_exhaustive]
423pub struct MaintenanceConfig {
424    #[serde(default)]
425    pub enabled: bool,
426    /// Paths exempt from maintenance mode (glob patterns).
427    #[serde(default = "default_exempt_paths")]
428    pub exempt_paths: Vec<String>,
429    #[serde(default = "default_maintenance_message")]
430    pub message: String,
431}
432
433fn default_exempt_paths() -> Vec<String> {
434    vec![
435        "/health/**".into(),
436        "/.well-known/**".into(),
437        "/metrics".into(),
438        "/auth/verify".into(),
439    ]
440}
441
442fn default_maintenance_message() -> String {
443    "Service is under maintenance. Please try again later.".into()
444}
445
446impl Default for MaintenanceConfig {
447    fn default() -> Self {
448        Self {
449            enabled: false,
450            exempt_paths: default_exempt_paths(),
451            message: default_maintenance_message(),
452        }
453    }
454}
455
456/// CORS configuration.
457#[derive(Debug, Clone, Default, Deserialize)]
458#[non_exhaustive]
459pub struct CorsConfig {
460    /// Allowed origins. Empty = permissive (dev mode).
461    #[serde(default)]
462    pub origins: Vec<String>,
463}
464
465/// Logging configuration.
466#[derive(Debug, Clone, Deserialize)]
467#[non_exhaustive]
468pub struct LoggingConfig {
469    #[serde(default = "default_log_level")]
470    pub level: String,
471    #[serde(default = "default_log_format")]
472    pub format: String,
473}
474
475fn default_log_level() -> String {
476    "info".into()
477}
478fn default_log_format() -> String {
479    "json".into()
480}
481
482impl Default for LoggingConfig {
483    fn default() -> Self {
484        Self {
485            level: default_log_level(),
486            format: default_log_format(),
487        }
488    }
489}
490
491/// Metrics endpoint classification.
492#[derive(Debug, Clone, Deserialize)]
493#[non_exhaustive]
494pub struct MetricsClassConfig {
495    /// Glob pattern for path matching.
496    pub pattern: String,
497    /// Label value for this class.
498    pub class: String,
499}
500
501impl ProxyConfig {
502    /// Load configuration from a YAML file.
503    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
504        Self::from_yaml_str(&std::fs::read_to_string(path)?)
505    }
506
507    /// Parse configuration from a YAML string.
508    ///
509    /// Useful for embedding the proxy: load a baked-in config (e.g. via
510    /// `include_str!`) without touching the filesystem.
511    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
512        Ok(serde_yaml::from_str(yaml)?)
513    }
514
515    /// Parse rate string like "20/min" → requests per window.
516    pub fn parse_rate(rate: &str) -> Option<u32> {
517        let parts: Vec<&str> = rate.split('/').collect();
518        if parts.len() != 2 {
519            return None;
520        }
521        parts[0].trim().parse().ok()
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_minimal_config_deserialize() {
531        let yaml = r#"
532upstream:
533  default: "grpc://localhost:4180"
534"#;
535        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
536        assert_eq!(config.upstream.default, "grpc://localhost:4180");
537        assert_eq!(config.listen.http, "0.0.0.0:8080");
538        assert_eq!(config.service.name, "structured-proxy");
539        assert!(config.descriptors.is_empty());
540        assert!(config.auth.is_none());
541        assert!(config.shield.is_none());
542    }
543
544    #[test]
545    fn test_full_config_deserialize() {
546        let yaml = r#"
547upstream:
548  default: "grpc://sid-identity:4180"
549
550descriptors:
551  - file: "/etc/proxy/sid.descriptor.bin"
552
553listen:
554  http: "0.0.0.0:9090"
555
556service:
557  name: "sid-proxy"
558
559aliases:
560  - from: "/oauth2/{path}"
561    to: "/v1/oauth2/{path}"
562
563auth:
564  mode: "jwt"
565  jwt:
566    issuer: "https://auth.example.com"
567    public_key_pem_file: "/etc/proxy/signing.pub"
568    claims_headers:
569      sub: "x-forwarded-user"
570      acr: "x-sid-auth-level"
571  forward_auth:
572    enabled: true
573    path: "/auth/verify"
574    policies:
575      - path: "/v1/admin/**"
576        require_auth: true
577        required_roles: ["admin"]
578      - path: "/v1/public/**"
579        require_auth: false
580  authz:
581    enabled: true
582    endpoint: "http://opa:9191"   # Envoy ext_authz server (gRPC)
583    timeout_ms: 200
584    failure_mode_allow: false      # fail closed: deny if authz is unreachable
585
586shield:
587  enabled: true
588  endpoint_classes:
589    - pattern: "/v1/auth/**"
590      class: "auth"
591      rate: "20/min"
592    - pattern: "/**"
593      class: "default"
594      rate: "100/min"
595  identifier_endpoints:
596    - path: "/v1/auth/opaque/login/start"
597      body_field: "identifier"
598      rate: "10/min"
599
600oidc_discovery:
601  enabled: true
602  issuer: "https://auth.example.com"
603
604maintenance:
605  enabled: false
606  exempt_paths:
607    - "/health/**"
608    - "/.well-known/**"
609
610cors:
611  origins:
612    - "https://app.example.com"
613
614metrics_classes:
615  - pattern: "/v1/auth/**"
616    class: "auth"
617  - pattern: "/v1/admin/**"
618    class: "admin"
619
620forwarded_headers:
621  - "authorization"
622  - "dpop"
623  - "x-request-id"
624"#;
625        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
626        assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
627        assert_eq!(config.listen.http, "0.0.0.0:9090");
628        assert_eq!(config.service.name, "sid-proxy");
629        assert_eq!(config.aliases.len(), 1);
630        assert!(config.auth.is_some());
631        let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
632        assert!(authz.enabled);
633        assert_eq!(authz.endpoint, "http://opa:9191");
634        assert_eq!(authz.timeout_ms, 200);
635        assert!(!authz.failure_mode_allow);
636        assert!(config.shield.is_some());
637        assert!(config.oidc_discovery.is_some());
638        assert_eq!(config.cors.origins.len(), 1);
639        assert_eq!(config.metrics_classes.len(), 2);
640        assert_eq!(config.forwarded_headers.len(), 3);
641    }
642
643    #[test]
644    fn authz_disabled_without_endpoint_parses() {
645        // A disabled authz block need not supply an endpoint.
646        let yaml = r#"
647upstream:
648  default: "grpc://localhost:4180"
649descriptors:
650  - file: "/x.bin"
651auth:
652  mode: "jwt"
653  authz:
654    enabled: false
655"#;
656        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
657        let authz = config.auth.unwrap().authz.unwrap();
658        assert!(!authz.enabled);
659        assert_eq!(authz.endpoint, "");
660    }
661
662    #[test]
663    fn test_descriptor_source_file() {
664        let yaml = r#"
665upstream:
666  default: "grpc://localhost:4180"
667descriptors:
668  - file: "/etc/proxy/service.descriptor.bin"
669"#;
670        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
671        assert_eq!(config.descriptors.len(), 1);
672        match &config.descriptors[0] {
673            DescriptorSource::File { file } => {
674                assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
675            }
676            _ => panic!("expected File descriptor source"),
677        }
678    }
679
680    #[test]
681    fn test_descriptor_source_reflection() {
682        let yaml = r#"
683upstream:
684  default: "grpc://localhost:4180"
685descriptors:
686  - reflection: "grpc://localhost:4180"
687"#;
688        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
689        match &config.descriptors[0] {
690            DescriptorSource::Reflection { reflection } => {
691                assert_eq!(reflection, "grpc://localhost:4180");
692            }
693            _ => panic!("expected Reflection descriptor source"),
694        }
695    }
696
697    #[test]
698    fn test_parse_rate() {
699        assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
700        assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
701        assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
702        assert_eq!(ProxyConfig::parse_rate("invalid"), None);
703    }
704
705    #[test]
706    fn test_openapi_config_deserialize() {
707        let yaml = r#"
708upstream:
709  default: "grpc://localhost:4180"
710openapi:
711  enabled: true
712  path: "/api/openapi.json"
713  docs_path: "/api/docs"
714  title: "Test API"
715  version: "2.0.0"
716"#;
717        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
718        let openapi = config.openapi.unwrap();
719        assert!(openapi.enabled);
720        assert_eq!(openapi.path, "/api/openapi.json");
721        assert_eq!(openapi.docs_path, "/api/docs");
722        assert_eq!(openapi.title.unwrap(), "Test API");
723        assert_eq!(openapi.version.unwrap(), "2.0.0");
724    }
725
726    #[test]
727    fn test_openapi_config_defaults() {
728        let yaml = r#"
729upstream:
730  default: "grpc://localhost:4180"
731openapi:
732  enabled: true
733"#;
734        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
735        let openapi = config.openapi.unwrap();
736        assert!(openapi.enabled);
737        assert_eq!(openapi.path, "/openapi.json");
738        assert_eq!(openapi.docs_path, "/docs");
739        assert!(openapi.title.is_none());
740        assert!(openapi.version.is_none());
741    }
742}