1use serde::Deserialize;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Deserialize)]
11#[non_exhaustive]
12pub struct ProxyConfig {
13 pub upstream: UpstreamConfig,
15
16 #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
18 pub descriptors: Vec<DescriptorSource>,
19
20 #[serde(default)]
22 pub listen: ListenConfig,
23
24 #[serde(default)]
26 pub service: ServiceConfig,
27
28 #[serde(default)]
30 pub aliases: Vec<AliasConfig>,
31
32 #[serde(default)]
34 pub openapi: Option<OpenApiConfig>,
35
36 #[serde(default)]
38 pub auth: Option<AuthConfig>,
39
40 #[serde(default)]
42 pub shield: Option<ShieldConfig>,
43
44 #[serde(default)]
46 pub oidc_discovery: Option<OidcDiscoveryConfig>,
47
48 #[serde(default)]
50 pub maintenance: MaintenanceConfig,
51
52 #[serde(default)]
54 pub cors: CorsConfig,
55
56 #[serde(default)]
58 pub logging: LoggingConfig,
59
60 #[serde(default)]
62 pub metrics_classes: Vec<MetricsClassConfig>,
63
64 #[serde(default = "default_forwarded_headers")]
66 pub forwarded_headers: Vec<String>,
67}
68
69fn default_forwarded_headers() -> Vec<String> {
70 vec![
71 "authorization".into(),
72 "dpop".into(),
73 "x-request-id".into(),
74 "x-forwarded-for".into(),
75 "x-forwarded-proto".into(),
76 "x-real-ip".into(),
77 "accept-language".into(),
78 "user-agent".into(),
79 "idempotency-key".into(),
80 ]
81}
82
83#[derive(Debug, Clone, Deserialize)]
85#[non_exhaustive]
86pub struct UpstreamConfig {
87 pub default: String,
89}
90
91#[derive(Debug, Clone)]
93#[non_exhaustive]
94pub enum DescriptorSource {
95 File { file: PathBuf },
97 Reflection { reflection: String },
99 Embedded { bytes: &'static [u8] },
101}
102
103#[derive(Debug, Clone, Deserialize)]
105#[serde(untagged)]
106enum DescriptorSourceYaml {
107 File { file: PathBuf },
108 Reflection { reflection: String },
109}
110
111impl From<DescriptorSourceYaml> for DescriptorSource {
112 fn from(yaml: DescriptorSourceYaml) -> Self {
113 match yaml {
114 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
115 DescriptorSourceYaml::Reflection { reflection } => {
116 DescriptorSource::Reflection { reflection }
117 }
118 }
119 }
120}
121
122fn deserialize_descriptor_sources<'de, D>(
123 deserializer: D,
124) -> std::result::Result<Vec<DescriptorSource>, D::Error>
125where
126 D: serde::Deserializer<'de>,
127{
128 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
129 Ok(yaml_sources.into_iter().map(Into::into).collect())
130}
131
132#[derive(Debug, Clone, Deserialize)]
134#[non_exhaustive]
135pub struct ListenConfig {
136 #[serde(default = "default_http_listen")]
138 pub http: String,
139}
140
141fn default_http_listen() -> String {
142 "0.0.0.0:8080".into()
143}
144
145impl Default for ListenConfig {
146 fn default() -> Self {
147 Self {
148 http: default_http_listen(),
149 }
150 }
151}
152
153#[derive(Debug, Clone, Deserialize)]
155#[non_exhaustive]
156pub struct ServiceConfig {
157 #[serde(default = "default_service_name")]
159 pub name: String,
160}
161
162fn default_service_name() -> String {
163 "structured-proxy".into()
164}
165
166impl Default for ServiceConfig {
167 fn default() -> Self {
168 Self {
169 name: default_service_name(),
170 }
171 }
172}
173
174#[derive(Debug, Clone, Deserialize)]
176#[non_exhaustive]
177pub struct AliasConfig {
178 pub from: String,
179 pub to: String,
180}
181
182#[derive(Debug, Clone, Deserialize)]
184#[non_exhaustive]
185pub struct OpenApiConfig {
186 #[serde(default = "default_true")]
187 pub enabled: bool,
188 #[serde(default = "default_openapi_path")]
190 pub path: String,
191 #[serde(default = "default_docs_path")]
193 pub docs_path: String,
194 #[serde(default)]
195 pub title: Option<String>,
196 #[serde(default)]
197 pub version: Option<String>,
198}
199
200fn default_openapi_path() -> String {
201 "/openapi.json".into()
202}
203
204fn default_docs_path() -> String {
205 "/docs".into()
206}
207
208fn default_true() -> bool {
209 true
210}
211
212#[derive(Debug, Clone, Deserialize)]
214#[non_exhaustive]
215pub struct AuthConfig {
216 #[serde(default = "default_auth_mode")]
218 pub mode: String,
219
220 #[serde(default)]
222 pub jwt: Option<JwtConfig>,
223
224 #[serde(default)]
226 pub forward_auth: Option<ForwardAuthConfig>,
227
228 #[serde(default)]
230 pub authz: Option<AuthzConfig>,
231}
232
233fn default_auth_mode() -> String {
234 "none".into()
235}
236
237#[derive(Debug, Clone, Deserialize)]
239#[non_exhaustive]
240pub struct JwtConfig {
241 #[serde(default)]
243 pub jwks_uri: Option<String>,
244 #[serde(default)]
246 pub issuer: Option<String>,
247 #[serde(default)]
249 pub audience: Option<String>,
250 #[serde(default)]
252 pub public_key_pem_file: Option<PathBuf>,
253 #[serde(default)]
255 pub claims_headers: std::collections::HashMap<String, String>,
256 #[serde(default = "default_roles_claim")]
259 pub roles_claim: String,
260}
261
262fn default_roles_claim() -> String {
263 "roles".into()
264}
265
266#[derive(Debug, Clone, Deserialize)]
268#[non_exhaustive]
269pub struct ForwardAuthConfig {
270 #[serde(default)]
271 pub enabled: bool,
272 #[serde(default = "default_forward_auth_path")]
273 pub path: String,
274 #[serde(default)]
276 pub policies: Vec<RoutePolicyConfig>,
277 #[serde(default)]
279 pub login_url: Option<String>,
280 #[serde(default)]
282 pub applications_path: Option<PathBuf>,
283}
284
285fn default_forward_auth_path() -> String {
286 "/auth/verify".into()
287}
288
289#[derive(Debug, Clone, Deserialize)]
291#[non_exhaustive]
292pub struct RoutePolicyConfig {
293 pub path: String,
294 #[serde(default = "default_methods_all")]
295 pub methods: Vec<String>,
296 #[serde(default)]
297 pub require_auth: bool,
298 #[serde(default)]
299 pub required_roles: Vec<String>,
300}
301
302fn default_methods_all() -> Vec<String> {
303 vec!["*".into()]
304}
305
306#[derive(Debug, Clone, Deserialize)]
310#[non_exhaustive]
311pub struct AuthzConfig {
312 #[serde(default)]
314 pub enabled: bool,
315 #[serde(default)]
318 pub endpoint: String,
319 #[serde(default = "default_authz_timeout_ms")]
321 pub timeout_ms: u64,
322 #[serde(default)]
325 pub failure_mode_allow: bool,
326}
327
328fn default_authz_timeout_ms() -> u64 {
329 200
330}
331
332#[derive(Debug, Clone, Deserialize)]
334#[non_exhaustive]
335pub struct ShieldConfig {
336 #[serde(default)]
337 pub enabled: bool,
338 #[serde(default)]
340 pub endpoint_classes: Vec<EndpointClassConfig>,
341 #[serde(default)]
343 pub identifier_endpoints: Vec<IdentifierEndpointConfig>,
344 #[serde(default = "default_window_secs")]
346 pub window_secs: u64,
347 #[serde(default)]
352 pub redis_url: Option<String>,
353 #[serde(default)]
359 pub trusted_proxies: Vec<String>,
360}
361
362fn default_window_secs() -> u64 {
363 60
364}
365
366#[derive(Debug, Clone, Deserialize)]
368#[non_exhaustive]
369pub struct EndpointClassConfig {
370 pub pattern: String,
372 pub class: String,
374 pub rate: String,
376}
377
378#[derive(Debug, Clone, Deserialize)]
380#[non_exhaustive]
381pub struct IdentifierEndpointConfig {
382 pub path: String,
383 pub body_field: String,
384 pub rate: String,
385}
386
387#[derive(Debug, Clone, Deserialize)]
389#[non_exhaustive]
390pub struct OidcDiscoveryConfig {
391 #[serde(default)]
392 pub enabled: bool,
393 pub issuer: String,
394 #[serde(default)]
395 pub authorization_endpoint: Option<String>,
396 #[serde(default)]
397 pub token_endpoint: Option<String>,
398 #[serde(default)]
399 pub userinfo_endpoint: Option<String>,
400 #[serde(default)]
401 pub jwks_uri: Option<String>,
402 #[serde(default)]
403 pub signing_key: Option<SigningKeyConfig>,
404}
405
406#[derive(Debug, Clone, Deserialize)]
408#[non_exhaustive]
409pub struct SigningKeyConfig {
410 #[serde(default = "default_algorithm")]
411 pub algorithm: String,
412 pub public_key_pem_file: PathBuf,
413}
414
415fn default_algorithm() -> String {
416 "EdDSA".into()
417}
418
419#[derive(Debug, Clone, Deserialize)]
421#[non_exhaustive]
422pub struct MaintenanceConfig {
423 #[serde(default)]
424 pub enabled: bool,
425 #[serde(default = "default_exempt_paths")]
427 pub exempt_paths: Vec<String>,
428 #[serde(default = "default_maintenance_message")]
429 pub message: String,
430}
431
432fn default_exempt_paths() -> Vec<String> {
433 vec![
434 "/health/**".into(),
435 "/.well-known/**".into(),
436 "/metrics".into(),
437 "/auth/verify".into(),
438 ]
439}
440
441fn default_maintenance_message() -> String {
442 "Service is under maintenance. Please try again later.".into()
443}
444
445impl Default for MaintenanceConfig {
446 fn default() -> Self {
447 Self {
448 enabled: false,
449 exempt_paths: default_exempt_paths(),
450 message: default_maintenance_message(),
451 }
452 }
453}
454
455#[derive(Debug, Clone, Default, Deserialize)]
457#[non_exhaustive]
458pub struct CorsConfig {
459 #[serde(default)]
461 pub origins: Vec<String>,
462}
463
464#[derive(Debug, Clone, Deserialize)]
466#[non_exhaustive]
467pub struct LoggingConfig {
468 #[serde(default = "default_log_level")]
469 pub level: String,
470 #[serde(default = "default_log_format")]
471 pub format: String,
472}
473
474fn default_log_level() -> String {
475 "info".into()
476}
477fn default_log_format() -> String {
478 "json".into()
479}
480
481impl Default for LoggingConfig {
482 fn default() -> Self {
483 Self {
484 level: default_log_level(),
485 format: default_log_format(),
486 }
487 }
488}
489
490#[derive(Debug, Clone, Deserialize)]
492#[non_exhaustive]
493pub struct MetricsClassConfig {
494 pub pattern: String,
496 pub class: String,
498}
499
500impl ProxyConfig {
501 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
503 let content = std::fs::read_to_string(path)?;
504 let config: Self = serde_yaml::from_str(&content)?;
505 Ok(config)
506 }
507
508 pub fn parse_rate(rate: &str) -> Option<u32> {
510 let parts: Vec<&str> = rate.split('/').collect();
511 if parts.len() != 2 {
512 return None;
513 }
514 parts[0].trim().parse().ok()
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_minimal_config_deserialize() {
524 let yaml = r#"
525upstream:
526 default: "grpc://localhost:4180"
527"#;
528 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
529 assert_eq!(config.upstream.default, "grpc://localhost:4180");
530 assert_eq!(config.listen.http, "0.0.0.0:8080");
531 assert_eq!(config.service.name, "structured-proxy");
532 assert!(config.descriptors.is_empty());
533 assert!(config.auth.is_none());
534 assert!(config.shield.is_none());
535 }
536
537 #[test]
538 fn test_full_config_deserialize() {
539 let yaml = r#"
540upstream:
541 default: "grpc://sid-identity:4180"
542
543descriptors:
544 - file: "/etc/proxy/sid.descriptor.bin"
545
546listen:
547 http: "0.0.0.0:9090"
548
549service:
550 name: "sid-proxy"
551
552aliases:
553 - from: "/oauth2/{path}"
554 to: "/v1/oauth2/{path}"
555
556auth:
557 mode: "jwt"
558 jwt:
559 issuer: "https://auth.example.com"
560 public_key_pem_file: "/etc/proxy/signing.pub"
561 claims_headers:
562 sub: "x-forwarded-user"
563 acr: "x-sid-auth-level"
564 forward_auth:
565 enabled: true
566 path: "/auth/verify"
567 policies:
568 - path: "/v1/admin/**"
569 require_auth: true
570 required_roles: ["admin"]
571 - path: "/v1/public/**"
572 require_auth: false
573 authz:
574 enabled: true
575 endpoint: "http://opa:9191" # Envoy ext_authz server (gRPC)
576 timeout_ms: 200
577 failure_mode_allow: false # fail closed: deny if authz is unreachable
578
579shield:
580 enabled: true
581 endpoint_classes:
582 - pattern: "/v1/auth/**"
583 class: "auth"
584 rate: "20/min"
585 - pattern: "/**"
586 class: "default"
587 rate: "100/min"
588 identifier_endpoints:
589 - path: "/v1/auth/opaque/login/start"
590 body_field: "identifier"
591 rate: "10/min"
592
593oidc_discovery:
594 enabled: true
595 issuer: "https://auth.example.com"
596
597maintenance:
598 enabled: false
599 exempt_paths:
600 - "/health/**"
601 - "/.well-known/**"
602
603cors:
604 origins:
605 - "https://app.example.com"
606
607metrics_classes:
608 - pattern: "/v1/auth/**"
609 class: "auth"
610 - pattern: "/v1/admin/**"
611 class: "admin"
612
613forwarded_headers:
614 - "authorization"
615 - "dpop"
616 - "x-request-id"
617"#;
618 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
619 assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
620 assert_eq!(config.listen.http, "0.0.0.0:9090");
621 assert_eq!(config.service.name, "sid-proxy");
622 assert_eq!(config.aliases.len(), 1);
623 assert!(config.auth.is_some());
624 let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
625 assert!(authz.enabled);
626 assert_eq!(authz.endpoint, "http://opa:9191");
627 assert_eq!(authz.timeout_ms, 200);
628 assert!(!authz.failure_mode_allow);
629 assert!(config.shield.is_some());
630 assert!(config.oidc_discovery.is_some());
631 assert_eq!(config.cors.origins.len(), 1);
632 assert_eq!(config.metrics_classes.len(), 2);
633 assert_eq!(config.forwarded_headers.len(), 3);
634 }
635
636 #[test]
637 fn authz_disabled_without_endpoint_parses() {
638 let yaml = r#"
640upstream:
641 default: "grpc://localhost:4180"
642descriptors:
643 - file: "/x.bin"
644auth:
645 mode: "jwt"
646 authz:
647 enabled: false
648"#;
649 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
650 let authz = config.auth.unwrap().authz.unwrap();
651 assert!(!authz.enabled);
652 assert_eq!(authz.endpoint, "");
653 }
654
655 #[test]
656 fn test_descriptor_source_file() {
657 let yaml = r#"
658upstream:
659 default: "grpc://localhost:4180"
660descriptors:
661 - file: "/etc/proxy/service.descriptor.bin"
662"#;
663 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
664 assert_eq!(config.descriptors.len(), 1);
665 match &config.descriptors[0] {
666 DescriptorSource::File { file } => {
667 assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
668 }
669 _ => panic!("expected File descriptor source"),
670 }
671 }
672
673 #[test]
674 fn test_descriptor_source_reflection() {
675 let yaml = r#"
676upstream:
677 default: "grpc://localhost:4180"
678descriptors:
679 - reflection: "grpc://localhost:4180"
680"#;
681 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
682 match &config.descriptors[0] {
683 DescriptorSource::Reflection { reflection } => {
684 assert_eq!(reflection, "grpc://localhost:4180");
685 }
686 _ => panic!("expected Reflection descriptor source"),
687 }
688 }
689
690 #[test]
691 fn test_parse_rate() {
692 assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
693 assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
694 assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
695 assert_eq!(ProxyConfig::parse_rate("invalid"), None);
696 }
697
698 #[test]
699 fn test_openapi_config_deserialize() {
700 let yaml = r#"
701upstream:
702 default: "grpc://localhost:4180"
703openapi:
704 enabled: true
705 path: "/api/openapi.json"
706 docs_path: "/api/docs"
707 title: "Test API"
708 version: "2.0.0"
709"#;
710 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
711 let openapi = config.openapi.unwrap();
712 assert!(openapi.enabled);
713 assert_eq!(openapi.path, "/api/openapi.json");
714 assert_eq!(openapi.docs_path, "/api/docs");
715 assert_eq!(openapi.title.unwrap(), "Test API");
716 assert_eq!(openapi.version.unwrap(), "2.0.0");
717 }
718
719 #[test]
720 fn test_openapi_config_defaults() {
721 let yaml = r#"
722upstream:
723 default: "grpc://localhost:4180"
724openapi:
725 enabled: true
726"#;
727 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
728 let openapi = config.openapi.unwrap();
729 assert!(openapi.enabled);
730 assert_eq!(openapi.path, "/openapi.json");
731 assert_eq!(openapi.docs_path, "/docs");
732 assert!(openapi.title.is_none());
733 assert!(openapi.version.is_none());
734 }
735}