1use serde::Deserialize;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18 pub upstream: UpstreamConfig,
20
21 #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
23 pub descriptors: Vec<DescriptorSource>,
24
25 #[serde(default)]
27 pub listen: ListenConfig,
28
29 #[serde(default)]
31 pub service: ServiceConfig,
32
33 #[serde(default)]
35 pub aliases: Vec<AliasConfig>,
36
37 #[serde(default)]
39 pub openapi: Option<OpenApiConfig>,
40
41 #[serde(default)]
43 pub auth: Option<AuthConfig>,
44
45 #[serde(default)]
47 pub shield: Option<ShieldConfig>,
48
49 #[serde(default)]
51 pub oidc_discovery: Option<OidcDiscoveryConfig>,
52
53 #[serde(default)]
55 pub maintenance: MaintenanceConfig,
56
57 #[serde(default)]
59 pub cors: CorsConfig,
60
61 #[serde(default)]
63 pub logging: LoggingConfig,
64
65 #[serde(default)]
67 pub metrics_classes: Vec<MetricsClassConfig>,
68
69 #[serde(default = "default_forwarded_headers")]
71 pub forwarded_headers: Vec<String>,
72
73 #[serde(default)]
75 pub streaming: StreamingConfig,
76}
77
78fn default_forwarded_headers() -> Vec<String> {
79 vec![
80 "authorization".into(),
81 "dpop".into(),
82 "x-request-id".into(),
83 "x-forwarded-for".into(),
84 "x-forwarded-proto".into(),
85 "x-real-ip".into(),
86 "accept-language".into(),
87 "user-agent".into(),
88 "idempotency-key".into(),
89 ]
90}
91
92#[derive(Debug, Clone, Deserialize)]
98pub struct StreamingConfig {
99 #[serde(default = "default_sse_keep_alive_secs")]
103 pub sse_keep_alive_secs: u64,
104}
105
106fn default_sse_keep_alive_secs() -> u64 {
107 15
108}
109
110impl Default for StreamingConfig {
111 fn default() -> Self {
112 Self {
113 sse_keep_alive_secs: default_sse_keep_alive_secs(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Deserialize)]
120pub struct UpstreamConfig {
121 pub default: String,
123}
124
125#[derive(Debug, Clone)]
127pub enum DescriptorSource {
128 File { file: PathBuf },
130 Reflection { reflection: String },
132 Embedded { bytes: &'static [u8] },
134}
135
136#[derive(Debug, Clone, Deserialize)]
138#[serde(untagged)]
139enum DescriptorSourceYaml {
140 File { file: PathBuf },
141 Reflection { reflection: String },
142}
143
144impl From<DescriptorSourceYaml> for DescriptorSource {
145 fn from(yaml: DescriptorSourceYaml) -> Self {
146 match yaml {
147 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
148 DescriptorSourceYaml::Reflection { reflection } => {
149 DescriptorSource::Reflection { reflection }
150 }
151 }
152 }
153}
154
155fn deserialize_descriptor_sources<'de, D>(
156 deserializer: D,
157) -> std::result::Result<Vec<DescriptorSource>, D::Error>
158where
159 D: serde::Deserializer<'de>,
160{
161 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
162 Ok(yaml_sources.into_iter().map(Into::into).collect())
163}
164
165#[derive(Debug, Clone, Deserialize)]
167pub struct ListenConfig {
168 #[serde(default = "default_http_listen")]
170 pub http: String,
171}
172
173fn default_http_listen() -> String {
174 "0.0.0.0:8080".into()
175}
176
177impl Default for ListenConfig {
178 fn default() -> Self {
179 Self {
180 http: default_http_listen(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Deserialize)]
187pub struct ServiceConfig {
188 #[serde(default = "default_service_name")]
190 pub name: String,
191}
192
193fn default_service_name() -> String {
194 "structured-proxy".into()
195}
196
197impl Default for ServiceConfig {
198 fn default() -> Self {
199 Self {
200 name: default_service_name(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, Deserialize)]
207#[non_exhaustive]
208pub struct AliasConfig {
209 pub from: String,
210 pub to: String,
211}
212
213#[derive(Debug, Clone, Deserialize)]
215#[non_exhaustive]
216pub struct OpenApiConfig {
217 #[serde(default = "default_true")]
218 pub enabled: bool,
219 #[serde(default = "default_openapi_path")]
221 pub path: String,
222 #[serde(default = "default_docs_path")]
224 pub docs_path: String,
225 #[serde(default)]
226 pub title: Option<String>,
227 #[serde(default)]
228 pub version: Option<String>,
229}
230
231fn default_openapi_path() -> String {
232 "/openapi.json".into()
233}
234
235fn default_docs_path() -> String {
236 "/docs".into()
237}
238
239fn default_true() -> bool {
240 true
241}
242
243#[derive(Debug, Clone, Deserialize)]
245#[non_exhaustive]
246pub struct AuthConfig {
247 #[serde(default = "default_auth_mode")]
249 pub mode: String,
250
251 #[serde(default)]
253 pub jwt: Option<JwtConfig>,
254
255 #[serde(default)]
257 pub forward_auth: Option<ForwardAuthConfig>,
258
259 #[serde(default)]
261 pub authz: Option<AuthzConfig>,
262}
263
264fn default_auth_mode() -> String {
265 "none".into()
266}
267
268#[derive(Debug, Clone, Deserialize)]
270#[non_exhaustive]
271pub struct JwtConfig {
272 #[serde(default)]
274 pub jwks_uri: Option<String>,
275 #[serde(default)]
277 pub issuer: Option<String>,
278 #[serde(default)]
280 pub audience: Option<String>,
281 #[serde(default)]
283 pub public_key_pem_file: Option<PathBuf>,
284 #[serde(default)]
286 pub claims_headers: std::collections::HashMap<String, String>,
287 #[serde(default = "default_roles_claim")]
290 pub roles_claim: String,
291}
292
293fn default_roles_claim() -> String {
294 "roles".into()
295}
296
297#[derive(Debug, Clone, Deserialize)]
299#[non_exhaustive]
300pub struct ForwardAuthConfig {
301 #[serde(default)]
302 pub enabled: bool,
303 #[serde(default = "default_forward_auth_path")]
304 pub path: String,
305 #[serde(default)]
307 pub policies: Vec<RoutePolicyConfig>,
308 #[serde(default)]
310 pub login_url: Option<String>,
311 #[serde(default)]
313 pub applications_path: Option<PathBuf>,
314}
315
316fn default_forward_auth_path() -> String {
317 "/auth/verify".into()
318}
319
320#[derive(Debug, Clone, Deserialize)]
322#[non_exhaustive]
323pub struct RoutePolicyConfig {
324 pub path: String,
325 #[serde(default = "default_methods_all")]
326 pub methods: Vec<String>,
327 #[serde(default)]
328 pub require_auth: bool,
329 #[serde(default)]
330 pub required_roles: Vec<String>,
331}
332
333fn default_methods_all() -> Vec<String> {
334 vec!["*".into()]
335}
336
337#[derive(Debug, Clone, Deserialize)]
341#[non_exhaustive]
342pub struct AuthzConfig {
343 #[serde(default)]
345 pub enabled: bool,
346 #[serde(default)]
349 pub endpoint: String,
350 #[serde(default = "default_authz_timeout_ms")]
352 pub timeout_ms: u64,
353 #[serde(default)]
356 pub failure_mode_allow: bool,
357}
358
359fn default_authz_timeout_ms() -> u64 {
360 200
361}
362
363#[derive(Debug, Clone, Deserialize)]
365#[non_exhaustive]
366pub struct ShieldConfig {
367 #[serde(default)]
368 pub enabled: bool,
369 #[serde(default)]
371 pub endpoint_classes: Vec<EndpointClassConfig>,
372 #[serde(default)]
374 pub identifier_endpoints: Vec<IdentifierEndpointConfig>,
375 #[serde(default = "default_window_secs")]
377 pub window_secs: u64,
378 #[serde(default)]
383 pub redis_url: Option<String>,
384 #[serde(default)]
390 pub trusted_proxies: Vec<String>,
391}
392
393fn default_window_secs() -> u64 {
394 60
395}
396
397#[derive(Debug, Clone, Deserialize)]
399#[non_exhaustive]
400pub struct EndpointClassConfig {
401 pub pattern: String,
403 pub class: String,
405 pub rate: String,
407}
408
409#[derive(Debug, Clone, Deserialize)]
411#[non_exhaustive]
412pub struct IdentifierEndpointConfig {
413 pub path: String,
414 pub body_field: String,
415 pub rate: String,
416}
417
418#[derive(Debug, Clone, Deserialize)]
420#[non_exhaustive]
421pub struct OidcDiscoveryConfig {
422 #[serde(default)]
423 pub enabled: bool,
424 pub issuer: String,
425 #[serde(default)]
426 pub authorization_endpoint: Option<String>,
427 #[serde(default)]
428 pub token_endpoint: Option<String>,
429 #[serde(default)]
430 pub userinfo_endpoint: Option<String>,
431 #[serde(default)]
432 pub jwks_uri: Option<String>,
433 #[serde(default)]
434 pub signing_key: Option<SigningKeyConfig>,
435}
436
437#[derive(Debug, Clone, Deserialize)]
439#[non_exhaustive]
440pub struct SigningKeyConfig {
441 #[serde(default = "default_algorithm")]
442 pub algorithm: String,
443 pub public_key_pem_file: PathBuf,
444}
445
446fn default_algorithm() -> String {
447 "EdDSA".into()
448}
449
450#[derive(Debug, Clone, Deserialize)]
452#[non_exhaustive]
453pub struct MaintenanceConfig {
454 #[serde(default)]
455 pub enabled: bool,
456 #[serde(default = "default_exempt_paths")]
458 pub exempt_paths: Vec<String>,
459 #[serde(default = "default_maintenance_message")]
460 pub message: String,
461}
462
463fn default_exempt_paths() -> Vec<String> {
464 vec![
465 "/health/**".into(),
466 "/.well-known/**".into(),
467 "/metrics".into(),
468 "/auth/verify".into(),
469 ]
470}
471
472fn default_maintenance_message() -> String {
473 "Service is under maintenance. Please try again later.".into()
474}
475
476impl Default for MaintenanceConfig {
477 fn default() -> Self {
478 Self {
479 enabled: false,
480 exempt_paths: default_exempt_paths(),
481 message: default_maintenance_message(),
482 }
483 }
484}
485
486#[derive(Debug, Clone, Default, Deserialize)]
488#[non_exhaustive]
489pub struct CorsConfig {
490 #[serde(default)]
492 pub origins: Vec<String>,
493}
494
495#[derive(Debug, Clone, Deserialize)]
497#[non_exhaustive]
498pub struct LoggingConfig {
499 #[serde(default = "default_log_level")]
500 pub level: String,
501 #[serde(default = "default_log_format")]
502 pub format: String,
503}
504
505fn default_log_level() -> String {
506 "info".into()
507}
508fn default_log_format() -> String {
509 "json".into()
510}
511
512impl Default for LoggingConfig {
513 fn default() -> Self {
514 Self {
515 level: default_log_level(),
516 format: default_log_format(),
517 }
518 }
519}
520
521#[derive(Debug, Clone, Deserialize)]
523#[non_exhaustive]
524pub struct MetricsClassConfig {
525 pub pattern: String,
527 pub class: String,
529}
530
531impl ProxyConfig {
532 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
534 Self::from_yaml_str(&std::fs::read_to_string(path)?)
535 }
536
537 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
542 let config: Self = serde_yaml::from_str(yaml)?;
543 config.validate()?;
544 Ok(config)
545 }
546
547 pub fn validate(&self) -> anyhow::Result<()> {
553 if self.streaming.sse_keep_alive_secs == 0 {
554 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
555 }
556 Ok(())
557 }
558
559 pub fn parse_rate(rate: &str) -> Option<u32> {
561 let parts: Vec<&str> = rate.split('/').collect();
562 if parts.len() != 2 {
563 return None;
564 }
565 parts[0].trim().parse().ok()
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn test_minimal_config_deserialize() {
575 let yaml = r#"
576upstream:
577 default: "grpc://localhost:4180"
578"#;
579 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
580 assert_eq!(config.upstream.default, "grpc://localhost:4180");
581 assert_eq!(config.listen.http, "0.0.0.0:8080");
582 assert_eq!(config.service.name, "structured-proxy");
583 assert_eq!(config.streaming.sse_keep_alive_secs, 15);
584 assert!(config.descriptors.is_empty());
585 assert!(config.auth.is_none());
586 assert!(config.shield.is_none());
587 }
588
589 #[test]
590 fn test_zero_sse_keep_alive_is_rejected() {
591 let yaml = r#"
594upstream:
595 default: "grpc://localhost:4180"
596streaming:
597 sse_keep_alive_secs: 0
598"#;
599 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
600 assert!(err.to_string().contains("sse_keep_alive_secs"));
601 }
602
603 #[test]
604 fn test_full_config_deserialize() {
605 let yaml = r#"
606upstream:
607 default: "grpc://sid-identity:4180"
608
609descriptors:
610 - file: "/etc/proxy/sid.descriptor.bin"
611
612listen:
613 http: "0.0.0.0:9090"
614
615service:
616 name: "sid-proxy"
617
618aliases:
619 - from: "/oauth2/{path}"
620 to: "/v1/oauth2/{path}"
621
622auth:
623 mode: "jwt"
624 jwt:
625 issuer: "https://auth.example.com"
626 public_key_pem_file: "/etc/proxy/signing.pub"
627 claims_headers:
628 sub: "x-forwarded-user"
629 acr: "x-sid-auth-level"
630 forward_auth:
631 enabled: true
632 path: "/auth/verify"
633 policies:
634 - path: "/v1/admin/**"
635 require_auth: true
636 required_roles: ["admin"]
637 - path: "/v1/public/**"
638 require_auth: false
639 authz:
640 enabled: true
641 endpoint: "http://opa:9191" # Envoy ext_authz server (gRPC)
642 timeout_ms: 200
643 failure_mode_allow: false # fail closed: deny if authz is unreachable
644
645shield:
646 enabled: true
647 endpoint_classes:
648 - pattern: "/v1/auth/**"
649 class: "auth"
650 rate: "20/min"
651 - pattern: "/**"
652 class: "default"
653 rate: "100/min"
654 identifier_endpoints:
655 - path: "/v1/auth/opaque/login/start"
656 body_field: "identifier"
657 rate: "10/min"
658
659oidc_discovery:
660 enabled: true
661 issuer: "https://auth.example.com"
662
663maintenance:
664 enabled: false
665 exempt_paths:
666 - "/health/**"
667 - "/.well-known/**"
668
669cors:
670 origins:
671 - "https://app.example.com"
672
673metrics_classes:
674 - pattern: "/v1/auth/**"
675 class: "auth"
676 - pattern: "/v1/admin/**"
677 class: "admin"
678
679forwarded_headers:
680 - "authorization"
681 - "dpop"
682 - "x-request-id"
683"#;
684 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
685 assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
686 assert_eq!(config.listen.http, "0.0.0.0:9090");
687 assert_eq!(config.service.name, "sid-proxy");
688 assert_eq!(config.aliases.len(), 1);
689 assert!(config.auth.is_some());
690 let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
691 assert!(authz.enabled);
692 assert_eq!(authz.endpoint, "http://opa:9191");
693 assert_eq!(authz.timeout_ms, 200);
694 assert!(!authz.failure_mode_allow);
695 assert!(config.shield.is_some());
696 assert!(config.oidc_discovery.is_some());
697 assert_eq!(config.cors.origins.len(), 1);
698 assert_eq!(config.metrics_classes.len(), 2);
699 assert_eq!(config.forwarded_headers.len(), 3);
700 }
701
702 #[test]
703 fn authz_disabled_without_endpoint_parses() {
704 let yaml = r#"
706upstream:
707 default: "grpc://localhost:4180"
708descriptors:
709 - file: "/x.bin"
710auth:
711 mode: "jwt"
712 authz:
713 enabled: false
714"#;
715 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
716 let authz = config.auth.unwrap().authz.unwrap();
717 assert!(!authz.enabled);
718 assert_eq!(authz.endpoint, "");
719 }
720
721 #[test]
722 fn test_descriptor_source_file() {
723 let yaml = r#"
724upstream:
725 default: "grpc://localhost:4180"
726descriptors:
727 - file: "/etc/proxy/service.descriptor.bin"
728"#;
729 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
730 assert_eq!(config.descriptors.len(), 1);
731 match &config.descriptors[0] {
732 DescriptorSource::File { file } => {
733 assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
734 }
735 _ => panic!("expected File descriptor source"),
736 }
737 }
738
739 #[test]
740 fn test_descriptor_source_reflection() {
741 let yaml = r#"
742upstream:
743 default: "grpc://localhost:4180"
744descriptors:
745 - reflection: "grpc://localhost:4180"
746"#;
747 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
748 match &config.descriptors[0] {
749 DescriptorSource::Reflection { reflection } => {
750 assert_eq!(reflection, "grpc://localhost:4180");
751 }
752 _ => panic!("expected Reflection descriptor source"),
753 }
754 }
755
756 #[test]
757 fn test_parse_rate() {
758 assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
759 assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
760 assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
761 assert_eq!(ProxyConfig::parse_rate("invalid"), None);
762 }
763
764 #[test]
765 fn test_openapi_config_deserialize() {
766 let yaml = r#"
767upstream:
768 default: "grpc://localhost:4180"
769openapi:
770 enabled: true
771 path: "/api/openapi.json"
772 docs_path: "/api/docs"
773 title: "Test API"
774 version: "2.0.0"
775"#;
776 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
777 let openapi = config.openapi.unwrap();
778 assert!(openapi.enabled);
779 assert_eq!(openapi.path, "/api/openapi.json");
780 assert_eq!(openapi.docs_path, "/api/docs");
781 assert_eq!(openapi.title.unwrap(), "Test API");
782 assert_eq!(openapi.version.unwrap(), "2.0.0");
783 }
784
785 #[test]
786 fn test_openapi_config_defaults() {
787 let yaml = r#"
788upstream:
789 default: "grpc://localhost:4180"
790openapi:
791 enabled: true
792"#;
793 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
794 let openapi = config.openapi.unwrap();
795 assert!(openapi.enabled);
796 assert_eq!(openapi.path, "/openapi.json");
797 assert_eq!(openapi.docs_path, "/docs");
798 assert!(openapi.title.is_none());
799 assert!(openapi.version.is_none());
800 }
801}