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
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#[derive(Debug, Clone, Deserialize)]
90pub struct UpstreamConfig {
91 pub default: String,
93}
94
95#[derive(Debug, Clone)]
97pub enum DescriptorSource {
98 File { file: PathBuf },
100 Reflection { reflection: String },
102 Embedded { bytes: &'static [u8] },
104}
105
106#[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#[derive(Debug, Clone, Deserialize)]
137pub struct ListenConfig {
138 #[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#[derive(Debug, Clone, Deserialize)]
157pub struct ServiceConfig {
158 #[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#[derive(Debug, Clone, Deserialize)]
177#[non_exhaustive]
178pub struct AliasConfig {
179 pub from: String,
180 pub to: String,
181}
182
183#[derive(Debug, Clone, Deserialize)]
185#[non_exhaustive]
186pub struct OpenApiConfig {
187 #[serde(default = "default_true")]
188 pub enabled: bool,
189 #[serde(default = "default_openapi_path")]
191 pub path: String,
192 #[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#[derive(Debug, Clone, Deserialize)]
215#[non_exhaustive]
216pub struct AuthConfig {
217 #[serde(default = "default_auth_mode")]
219 pub mode: String,
220
221 #[serde(default)]
223 pub jwt: Option<JwtConfig>,
224
225 #[serde(default)]
227 pub forward_auth: Option<ForwardAuthConfig>,
228
229 #[serde(default)]
231 pub authz: Option<AuthzConfig>,
232}
233
234fn default_auth_mode() -> String {
235 "none".into()
236}
237
238#[derive(Debug, Clone, Deserialize)]
240#[non_exhaustive]
241pub struct JwtConfig {
242 #[serde(default)]
244 pub jwks_uri: Option<String>,
245 #[serde(default)]
247 pub issuer: Option<String>,
248 #[serde(default)]
250 pub audience: Option<String>,
251 #[serde(default)]
253 pub public_key_pem_file: Option<PathBuf>,
254 #[serde(default)]
256 pub claims_headers: std::collections::HashMap<String, String>,
257 #[serde(default = "default_roles_claim")]
260 pub roles_claim: String,
261}
262
263fn default_roles_claim() -> String {
264 "roles".into()
265}
266
267#[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 #[serde(default)]
277 pub policies: Vec<RoutePolicyConfig>,
278 #[serde(default)]
280 pub login_url: Option<String>,
281 #[serde(default)]
283 pub applications_path: Option<PathBuf>,
284}
285
286fn default_forward_auth_path() -> String {
287 "/auth/verify".into()
288}
289
290#[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#[derive(Debug, Clone, Deserialize)]
311#[non_exhaustive]
312pub struct AuthzConfig {
313 #[serde(default)]
315 pub enabled: bool,
316 #[serde(default)]
319 pub endpoint: String,
320 #[serde(default = "default_authz_timeout_ms")]
322 pub timeout_ms: u64,
323 #[serde(default)]
326 pub failure_mode_allow: bool,
327}
328
329fn default_authz_timeout_ms() -> u64 {
330 200
331}
332
333#[derive(Debug, Clone, Deserialize)]
335#[non_exhaustive]
336pub struct ShieldConfig {
337 #[serde(default)]
338 pub enabled: bool,
339 #[serde(default)]
341 pub endpoint_classes: Vec<EndpointClassConfig>,
342 #[serde(default)]
344 pub identifier_endpoints: Vec<IdentifierEndpointConfig>,
345 #[serde(default = "default_window_secs")]
347 pub window_secs: u64,
348 #[serde(default)]
353 pub redis_url: Option<String>,
354 #[serde(default)]
360 pub trusted_proxies: Vec<String>,
361}
362
363fn default_window_secs() -> u64 {
364 60
365}
366
367#[derive(Debug, Clone, Deserialize)]
369#[non_exhaustive]
370pub struct EndpointClassConfig {
371 pub pattern: String,
373 pub class: String,
375 pub rate: String,
377}
378
379#[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#[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#[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#[derive(Debug, Clone, Deserialize)]
422#[non_exhaustive]
423pub struct MaintenanceConfig {
424 #[serde(default)]
425 pub enabled: bool,
426 #[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#[derive(Debug, Clone, Default, Deserialize)]
458#[non_exhaustive]
459pub struct CorsConfig {
460 #[serde(default)]
462 pub origins: Vec<String>,
463}
464
465#[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#[derive(Debug, Clone, Deserialize)]
493#[non_exhaustive]
494pub struct MetricsClassConfig {
495 pub pattern: String,
497 pub class: String,
499}
500
501impl ProxyConfig {
502 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 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
512 Ok(serde_yaml::from_str(yaml)?)
513 }
514
515 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 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}