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 health: HealthConfig,
56
57 #[serde(default)]
59 pub metrics: MetricsConfig,
60
61 #[serde(default)]
63 pub maintenance: MaintenanceConfig,
64
65 #[serde(default)]
67 pub cors: CorsConfig,
68
69 #[serde(default)]
71 pub logging: LoggingConfig,
72
73 #[serde(default)]
75 pub metrics_classes: Vec<MetricsClassConfig>,
76
77 #[serde(default = "default_forwarded_headers")]
79 pub forwarded_headers: Vec<String>,
80
81 #[serde(default)]
83 pub streaming: StreamingConfig,
84}
85
86fn default_forwarded_headers() -> Vec<String> {
87 vec![
88 "authorization".into(),
89 "dpop".into(),
90 "x-request-id".into(),
91 "x-forwarded-for".into(),
92 "x-forwarded-proto".into(),
93 "x-real-ip".into(),
94 "accept-language".into(),
95 "user-agent".into(),
96 "idempotency-key".into(),
97 ]
98}
99
100#[derive(Debug, Clone, Deserialize)]
106pub struct StreamingConfig {
107 #[serde(default = "default_sse_keep_alive_secs")]
111 pub sse_keep_alive_secs: u64,
112}
113
114fn default_sse_keep_alive_secs() -> u64 {
115 15
116}
117
118impl Default for StreamingConfig {
119 fn default() -> Self {
120 Self {
121 sse_keep_alive_secs: default_sse_keep_alive_secs(),
122 }
123 }
124}
125
126#[derive(Debug, Clone, Deserialize)]
128pub struct UpstreamConfig {
129 pub default: String,
131}
132
133#[derive(Debug, Clone)]
135pub enum DescriptorSource {
136 File { file: PathBuf },
138 Reflection { reflection: String },
140 Embedded { bytes: &'static [u8] },
142}
143
144#[derive(Debug, Clone, Deserialize)]
146#[serde(untagged)]
147enum DescriptorSourceYaml {
148 File { file: PathBuf },
149 Reflection { reflection: String },
150}
151
152impl From<DescriptorSourceYaml> for DescriptorSource {
153 fn from(yaml: DescriptorSourceYaml) -> Self {
154 match yaml {
155 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
156 DescriptorSourceYaml::Reflection { reflection } => {
157 DescriptorSource::Reflection { reflection }
158 }
159 }
160 }
161}
162
163fn deserialize_descriptor_sources<'de, D>(
164 deserializer: D,
165) -> std::result::Result<Vec<DescriptorSource>, D::Error>
166where
167 D: serde::Deserializer<'de>,
168{
169 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
170 Ok(yaml_sources.into_iter().map(Into::into).collect())
171}
172
173#[derive(Debug, Clone, Deserialize)]
175pub struct ListenConfig {
176 #[serde(default = "default_http_listen")]
178 pub http: String,
179}
180
181fn default_http_listen() -> String {
182 "0.0.0.0:8080".into()
183}
184
185impl Default for ListenConfig {
186 fn default() -> Self {
187 Self {
188 http: default_http_listen(),
189 }
190 }
191}
192
193#[derive(Debug, Clone, Deserialize)]
195pub struct ServiceConfig {
196 #[serde(default = "default_service_name")]
198 pub name: String,
199}
200
201fn default_service_name() -> String {
202 "structured-proxy".into()
203}
204
205impl Default for ServiceConfig {
206 fn default() -> Self {
207 Self {
208 name: default_service_name(),
209 }
210 }
211}
212
213#[derive(Debug, Clone, Deserialize)]
215#[non_exhaustive]
216pub struct AliasConfig {
217 pub from: String,
218 pub to: String,
219}
220
221#[derive(Debug, Clone, Deserialize)]
223#[non_exhaustive]
224pub struct OpenApiConfig {
225 #[serde(default = "default_true")]
226 pub enabled: bool,
227 #[serde(default = "default_openapi_path")]
229 pub path: String,
230 #[serde(default = "default_docs_path")]
232 pub docs_path: String,
233 #[serde(default)]
234 pub title: Option<String>,
235 #[serde(default)]
236 pub version: Option<String>,
237}
238
239fn default_openapi_path() -> String {
240 "/openapi.json".into()
241}
242
243fn default_docs_path() -> String {
244 "/docs".into()
245}
246
247fn default_true() -> bool {
248 true
249}
250
251#[derive(Debug, Clone, Deserialize)]
253#[non_exhaustive]
254pub struct AuthConfig {
255 #[serde(default = "default_auth_mode")]
257 pub mode: String,
258
259 #[serde(default)]
261 pub jwt: Option<JwtConfig>,
262
263 #[serde(default)]
265 pub forward_auth: Option<ForwardAuthConfig>,
266
267 #[serde(default)]
269 pub authz: Option<AuthzConfig>,
270}
271
272fn default_auth_mode() -> String {
273 "none".into()
274}
275
276#[derive(Debug, Clone, Deserialize)]
278#[non_exhaustive]
279pub struct JwtConfig {
280 #[serde(default)]
282 pub jwks_uri: Option<String>,
283 #[serde(default)]
285 pub issuer: Option<String>,
286 #[serde(default)]
288 pub audience: Option<String>,
289 #[serde(default)]
291 pub public_key_pem_file: Option<PathBuf>,
292 #[serde(default)]
294 pub claims_headers: std::collections::HashMap<String, String>,
295 #[serde(default = "default_roles_claim")]
298 pub roles_claim: String,
299}
300
301fn default_roles_claim() -> String {
302 "roles".into()
303}
304
305#[derive(Debug, Clone, Deserialize)]
307#[non_exhaustive]
308pub struct ForwardAuthConfig {
309 #[serde(default)]
310 pub enabled: bool,
311 #[serde(default = "default_forward_auth_path")]
312 pub path: String,
313 #[serde(default)]
315 pub policies: Vec<RoutePolicyConfig>,
316 #[serde(default)]
318 pub login_url: Option<String>,
319 #[serde(default)]
321 pub applications_path: Option<PathBuf>,
322}
323
324fn default_forward_auth_path() -> String {
325 "/auth/verify".into()
326}
327
328#[derive(Debug, Clone, Deserialize)]
330#[non_exhaustive]
331pub struct RoutePolicyConfig {
332 pub path: String,
333 #[serde(default = "default_methods_all")]
334 pub methods: Vec<String>,
335 #[serde(default)]
336 pub require_auth: bool,
337 #[serde(default)]
338 pub required_roles: Vec<String>,
339}
340
341fn default_methods_all() -> Vec<String> {
342 vec!["*".into()]
343}
344
345#[derive(Debug, Clone, Deserialize)]
349#[non_exhaustive]
350pub struct AuthzConfig {
351 #[serde(default)]
353 pub enabled: bool,
354 #[serde(default)]
357 pub endpoint: String,
358 #[serde(default = "default_authz_timeout_ms")]
360 pub timeout_ms: u64,
361 #[serde(default)]
364 pub failure_mode_allow: bool,
365}
366
367fn default_authz_timeout_ms() -> u64 {
368 200
369}
370
371#[derive(Debug, Clone, Deserialize)]
379#[serde(deny_unknown_fields)]
380#[non_exhaustive]
381pub struct ShieldConfig {
382 #[serde(default)]
383 pub enabled: bool,
384 #[serde(default)]
387 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
388 #[serde(default)]
391 pub rules: Vec<RateRuleConfig>,
392 #[serde(default)]
396 pub default_profile: Option<String>,
397 #[serde(default)]
400 pub jwt_limits: Option<JwtLimitConfig>,
401 #[serde(default)]
404 pub limit_service: Option<LimitServiceConfig>,
405 #[serde(default)]
408 pub sync: Option<SyncConfig>,
409 #[serde(default)]
415 pub trusted_proxies: Vec<String>,
416}
417
418#[derive(Debug, Clone, Deserialize)]
420#[serde(deny_unknown_fields)]
421#[non_exhaustive]
422pub struct LimitProfileConfig {
423 pub rate: String,
426 #[serde(default)]
429 pub burst: Option<u64>,
430}
431
432#[derive(Debug, Clone, Deserialize)]
439#[serde(deny_unknown_fields)]
440#[non_exhaustive]
441pub struct RateRuleConfig {
442 pub pattern: String,
444 #[serde(default)]
446 pub key: KeySourceConfig,
447 #[serde(default)]
450 pub profile: Option<String>,
451}
452
453#[derive(Debug, Clone, Default, PartialEq, Eq)]
458#[non_exhaustive]
459pub enum KeySourceConfig {
460 #[default]
462 Ip,
463 Header {
466 name: String,
468 },
469 JwtClaim {
472 claim: String,
474 },
475}
476
477#[derive(Deserialize)]
484#[serde(deny_unknown_fields, rename_all = "snake_case")]
485struct KeySourceRaw {
486 #[serde(rename = "type")]
487 kind: String,
488 #[serde(default)]
489 name: Option<String>,
490 #[serde(default)]
491 claim: Option<String>,
492}
493
494impl<'de> Deserialize<'de> for KeySourceConfig {
495 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
496 where
497 D: serde::Deserializer<'de>,
498 {
499 use serde::de::Error;
500 let raw = KeySourceRaw::deserialize(deserializer)?;
501 match raw.kind.as_str() {
502 "ip" => {
503 if raw.name.is_some() || raw.claim.is_some() {
504 return Err(D::Error::custom("key type 'ip' takes no other fields"));
505 }
506 Ok(Self::Ip)
507 }
508 "header" => {
509 if raw.claim.is_some() {
510 return Err(D::Error::custom(
511 "key type 'header' takes 'name', not 'claim'",
512 ));
513 }
514 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
515 Ok(Self::Header { name })
516 }
517 "jwt_claim" => {
518 if raw.name.is_some() {
519 return Err(D::Error::custom(
520 "key type 'jwt_claim' takes 'claim', not 'name'",
521 ));
522 }
523 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
524 Ok(Self::JwtClaim { claim })
525 }
526 other => Err(D::Error::unknown_variant(
527 other,
528 &["ip", "header", "jwt_claim"],
529 )),
530 }
531 }
532}
533
534#[derive(Debug, Clone, Deserialize)]
538#[serde(deny_unknown_fields)]
539#[non_exhaustive]
540pub struct JwtLimitConfig {
541 #[serde(default = "default_tier_claim")]
543 pub tier_claim: String,
544 #[serde(default = "default_rpm_claim")]
547 pub rpm_claim: String,
548 #[serde(default = "default_burst_claim")]
550 pub burst_claim: String,
551}
552
553fn default_tier_claim() -> String {
554 "ratelimit_tier".to_string()
555}
556fn default_rpm_claim() -> String {
557 "ratelimit_rpm".to_string()
558}
559fn default_burst_claim() -> String {
560 "ratelimit_burst".to_string()
561}
562
563#[derive(Debug, Clone, Deserialize)]
567#[serde(deny_unknown_fields)]
568#[non_exhaustive]
569pub struct LimitServiceConfig {
570 pub endpoint: String,
573 #[serde(default = "default_limit_ttl_secs")]
576 pub ttl_secs: u64,
577 #[serde(default = "default_limit_timeout_ms")]
579 pub timeout_ms: u64,
580}
581
582fn default_limit_ttl_secs() -> u64 {
583 300
584}
585fn default_limit_timeout_ms() -> u64 {
586 500
587}
588
589#[derive(Debug, Clone, Deserialize)]
591#[serde(deny_unknown_fields)]
592#[non_exhaustive]
593pub struct SyncConfig {
594 pub redis_url: String,
597 #[serde(default = "default_sync_interval_ms")]
600 pub interval_ms: u64,
601}
602
603fn default_sync_interval_ms() -> u64 {
604 500
605}
606
607#[derive(Debug, Clone, Deserialize)]
609#[non_exhaustive]
610pub struct OidcDiscoveryConfig {
611 #[serde(default)]
612 pub enabled: bool,
613 pub issuer: String,
614 #[serde(default)]
615 pub authorization_endpoint: Option<String>,
616 #[serde(default)]
617 pub token_endpoint: Option<String>,
618 #[serde(default)]
619 pub userinfo_endpoint: Option<String>,
620 #[serde(default)]
621 pub jwks_uri: Option<String>,
622 #[serde(default)]
623 pub signing_key: Option<SigningKeyConfig>,
624}
625
626#[derive(Debug, Clone, Deserialize)]
628#[non_exhaustive]
629pub struct SigningKeyConfig {
630 #[serde(default = "default_algorithm")]
631 pub algorithm: String,
632 pub public_key_pem_file: PathBuf,
633}
634
635fn default_algorithm() -> String {
636 "EdDSA".into()
637}
638
639#[derive(Debug, Clone, Deserialize)]
645#[non_exhaustive]
646pub struct HealthConfig {
647 #[serde(default = "default_true")]
649 pub enabled: bool,
650 #[serde(default = "default_health_path")]
652 pub path: String,
653 #[serde(default = "default_health_live_path")]
655 pub live_path: String,
656 #[serde(default = "default_health_ready_path")]
658 pub ready_path: String,
659 #[serde(default = "default_health_startup_path")]
661 pub startup_path: String,
662}
663
664fn default_health_path() -> String {
665 "/health".into()
666}
667fn default_health_live_path() -> String {
668 "/health/live".into()
669}
670fn default_health_ready_path() -> String {
671 "/health/ready".into()
672}
673fn default_health_startup_path() -> String {
674 "/health/startup".into()
675}
676
677impl Default for HealthConfig {
678 fn default() -> Self {
679 Self {
680 enabled: true,
681 path: default_health_path(),
682 live_path: default_health_live_path(),
683 ready_path: default_health_ready_path(),
684 startup_path: default_health_startup_path(),
685 }
686 }
687}
688
689#[derive(Debug, Clone, Deserialize)]
691#[non_exhaustive]
692pub struct MetricsConfig {
693 #[serde(default = "default_true")]
695 pub enabled: bool,
696 #[serde(default = "default_metrics_path")]
698 pub path: String,
699}
700
701fn default_metrics_path() -> String {
702 "/metrics".into()
703}
704
705impl Default for MetricsConfig {
706 fn default() -> Self {
707 Self {
708 enabled: true,
709 path: default_metrics_path(),
710 }
711 }
712}
713
714#[derive(Debug, Clone, Deserialize)]
716#[non_exhaustive]
717pub struct MaintenanceConfig {
718 #[serde(default)]
719 pub enabled: bool,
720 #[serde(default = "default_exempt_paths")]
722 pub exempt_paths: Vec<String>,
723 #[serde(default = "default_maintenance_message")]
724 pub message: String,
725}
726
727fn default_exempt_paths() -> Vec<String> {
728 vec![
729 "/health/**".into(),
730 "/.well-known/**".into(),
731 "/metrics".into(),
732 "/auth/verify".into(),
733 ]
734}
735
736fn default_maintenance_message() -> String {
737 "Service is under maintenance. Please try again later.".into()
738}
739
740impl Default for MaintenanceConfig {
741 fn default() -> Self {
742 Self {
743 enabled: false,
744 exempt_paths: default_exempt_paths(),
745 message: default_maintenance_message(),
746 }
747 }
748}
749
750#[derive(Debug, Clone, Default, Deserialize)]
752#[non_exhaustive]
753pub struct CorsConfig {
754 #[serde(default)]
756 pub origins: Vec<String>,
757}
758
759#[derive(Debug, Clone, Deserialize)]
761#[non_exhaustive]
762pub struct LoggingConfig {
763 #[serde(default = "default_log_level")]
764 pub level: String,
765 #[serde(default = "default_log_format")]
766 pub format: String,
767}
768
769fn default_log_level() -> String {
770 "info".into()
771}
772fn default_log_format() -> String {
773 "json".into()
774}
775
776impl Default for LoggingConfig {
777 fn default() -> Self {
778 Self {
779 level: default_log_level(),
780 format: default_log_format(),
781 }
782 }
783}
784
785#[derive(Debug, Clone, Deserialize)]
787#[non_exhaustive]
788pub struct MetricsClassConfig {
789 pub pattern: String,
791 pub class: String,
793}
794
795impl ProxyConfig {
796 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
798 Self::from_yaml_str(&std::fs::read_to_string(path)?)
799 }
800
801 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
806 let config: Self = serde_yaml::from_str(yaml)?;
807 config.validate()?;
808 Ok(config)
809 }
810
811 pub fn validate(&self) -> anyhow::Result<()> {
817 if self.streaming.sse_keep_alive_secs == 0 {
818 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
819 }
820 self.validate_edge_paths()?;
821 Ok(())
822 }
823
824 fn validate_edge_paths(&self) -> anyhow::Result<()> {
829 let mut seen = std::collections::HashSet::new();
830 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
831 if !path.starts_with('/') {
832 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
833 }
834 if !seen.insert(path.to_string()) {
835 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
836 }
837 Ok(())
838 };
839 if self.health.enabled {
840 check("health.path", &self.health.path)?;
841 check("health.live_path", &self.health.live_path)?;
842 check("health.ready_path", &self.health.ready_path)?;
843 check("health.startup_path", &self.health.startup_path)?;
844 }
845 if self.metrics.enabled {
846 check("metrics.path", &self.metrics.path)?;
847 }
848 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
849 check("openapi.path", &openapi.path)?;
850 check("openapi.docs_path", &openapi.docs_path)?;
851 }
852 Ok(())
853 }
854
855 pub fn parse_rate(rate: &str) -> Option<u32> {
857 let parts: Vec<&str> = rate.split('/').collect();
858 if parts.len() != 2 {
859 return None;
860 }
861 parts[0].trim().parse().ok()
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 #[test]
870 fn test_minimal_config_deserialize() {
871 let yaml = r#"
872upstream:
873 default: "grpc://localhost:4180"
874"#;
875 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
876 assert_eq!(config.upstream.default, "grpc://localhost:4180");
877 assert_eq!(config.listen.http, "0.0.0.0:8080");
878 assert_eq!(config.service.name, "structured-proxy");
879 assert_eq!(config.streaming.sse_keep_alive_secs, 15);
880 assert!(config.descriptors.is_empty());
881 assert!(config.auth.is_none());
882 assert!(config.shield.is_none());
883 }
884
885 #[test]
886 fn health_and_metrics_defaults_and_overrides() {
887 let min: ProxyConfig =
889 serde_yaml::from_str("upstream:\n default: \"grpc://x:1\"\n").unwrap();
890 assert!(min.health.enabled);
891 assert_eq!(min.health.path, "/health");
892 assert_eq!(min.health.ready_path, "/health/ready");
893 assert!(min.metrics.enabled);
894 assert_eq!(min.metrics.path, "/metrics");
895
896 let yaml = r#"
898upstream:
899 default: "grpc://x:1"
900health:
901 path: "/internal/health"
902metrics:
903 enabled: false
904 path: "/internal/metrics"
905"#;
906 let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
907 assert_eq!(cfg.health.path, "/internal/health");
908 assert_eq!(cfg.health.live_path, "/health/live");
910 assert!(!cfg.metrics.enabled);
911 assert_eq!(cfg.metrics.path, "/internal/metrics");
912 }
913
914 #[test]
915 fn duplicate_probe_paths_are_rejected() {
916 let yaml = r#"
919upstream:
920 default: "grpc://x:1"
921health:
922 path: "/health/live"
923"#;
924 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
925 assert!(err.to_string().contains("duplicate endpoint path"));
926
927 let yaml2 = r#"
929upstream:
930 default: "grpc://x:1"
931metrics:
932 path: "/health"
933"#;
934 let err2 = ProxyConfig::from_yaml_str(yaml2).unwrap_err();
935 assert!(err2.to_string().contains("duplicate endpoint path"));
936
937 let yaml3 = r#"
939upstream:
940 default: "grpc://x:1"
941health:
942 enabled: false
943 path: "/metrics"
944"#;
945 assert!(ProxyConfig::from_yaml_str(yaml3).is_ok());
946 }
947
948 #[test]
949 fn malformed_edge_path_is_rejected() {
950 let yaml = r#"
953upstream:
954 default: "grpc://x:1"
955health:
956 path: "health"
957"#;
958 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
959 assert!(err.to_string().contains("must start with '/'"));
960 }
961
962 #[test]
963 fn test_zero_sse_keep_alive_is_rejected() {
964 let yaml = r#"
967upstream:
968 default: "grpc://localhost:4180"
969streaming:
970 sse_keep_alive_secs: 0
971"#;
972 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
973 assert!(err.to_string().contains("sse_keep_alive_secs"));
974 }
975
976 #[test]
977 fn test_full_config_deserialize() {
978 let yaml = r#"
979upstream:
980 default: "grpc://sid-identity:4180"
981
982descriptors:
983 - file: "/etc/proxy/sid.descriptor.bin"
984
985listen:
986 http: "0.0.0.0:9090"
987
988service:
989 name: "sid-proxy"
990
991aliases:
992 - from: "/oauth2/{path}"
993 to: "/v1/oauth2/{path}"
994
995auth:
996 mode: "jwt"
997 jwt:
998 issuer: "https://auth.example.com"
999 public_key_pem_file: "/etc/proxy/signing.pub"
1000 claims_headers:
1001 sub: "x-forwarded-user"
1002 acr: "x-sid-auth-level"
1003 forward_auth:
1004 enabled: true
1005 path: "/auth/verify"
1006 policies:
1007 - path: "/v1/admin/**"
1008 require_auth: true
1009 required_roles: ["admin"]
1010 - path: "/v1/public/**"
1011 require_auth: false
1012 authz:
1013 enabled: true
1014 endpoint: "http://opa:9191" # Envoy ext_authz server (gRPC)
1015 timeout_ms: 200
1016 failure_mode_allow: false # fail closed: deny if authz is unreachable
1017
1018shield:
1019 enabled: true
1020 profiles:
1021 auth: { rate: "20/min", burst: 5 }
1022 default: { rate: "100/min" }
1023 premium: { rate: "1000/min", burst: 50 }
1024 default_profile: "default"
1025 jwt_limits:
1026 tier_claim: "ratelimit_tier"
1027 rules:
1028 - pattern: "/v1/auth/**"
1029 key: { type: ip }
1030 profile: "auth"
1031 - pattern: "/v1/**"
1032 key: { type: jwt_claim, claim: "sub" }
1033 trusted_proxies: ["10.0.0.0/8"]
1034
1035oidc_discovery:
1036 enabled: true
1037 issuer: "https://auth.example.com"
1038
1039maintenance:
1040 enabled: false
1041 exempt_paths:
1042 - "/health/**"
1043 - "/.well-known/**"
1044
1045cors:
1046 origins:
1047 - "https://app.example.com"
1048
1049metrics_classes:
1050 - pattern: "/v1/auth/**"
1051 class: "auth"
1052 - pattern: "/v1/admin/**"
1053 class: "admin"
1054
1055forwarded_headers:
1056 - "authorization"
1057 - "dpop"
1058 - "x-request-id"
1059"#;
1060 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1061 assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
1062 assert_eq!(config.listen.http, "0.0.0.0:9090");
1063 assert_eq!(config.service.name, "sid-proxy");
1064 assert_eq!(config.aliases.len(), 1);
1065 assert!(config.auth.is_some());
1066 let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
1067 assert!(authz.enabled);
1068 assert_eq!(authz.endpoint, "http://opa:9191");
1069 assert_eq!(authz.timeout_ms, 200);
1070 assert!(!authz.failure_mode_allow);
1071 assert!(config.shield.is_some());
1072 assert!(config.oidc_discovery.is_some());
1073 assert_eq!(config.cors.origins.len(), 1);
1074 assert_eq!(config.metrics_classes.len(), 2);
1075 assert_eq!(config.forwarded_headers.len(), 3);
1076 }
1077
1078 #[test]
1079 fn authz_disabled_without_endpoint_parses() {
1080 let yaml = r#"
1082upstream:
1083 default: "grpc://localhost:4180"
1084descriptors:
1085 - file: "/x.bin"
1086auth:
1087 mode: "jwt"
1088 authz:
1089 enabled: false
1090"#;
1091 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1092 let authz = config.auth.unwrap().authz.unwrap();
1093 assert!(!authz.enabled);
1094 assert_eq!(authz.endpoint, "");
1095 }
1096
1097 #[test]
1098 fn test_descriptor_source_file() {
1099 let yaml = r#"
1100upstream:
1101 default: "grpc://localhost:4180"
1102descriptors:
1103 - file: "/etc/proxy/service.descriptor.bin"
1104"#;
1105 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1106 assert_eq!(config.descriptors.len(), 1);
1107 match &config.descriptors[0] {
1108 DescriptorSource::File { file } => {
1109 assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
1110 }
1111 _ => panic!("expected File descriptor source"),
1112 }
1113 }
1114
1115 #[test]
1116 fn test_descriptor_source_reflection() {
1117 let yaml = r#"
1118upstream:
1119 default: "grpc://localhost:4180"
1120descriptors:
1121 - reflection: "grpc://localhost:4180"
1122"#;
1123 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1124 match &config.descriptors[0] {
1125 DescriptorSource::Reflection { reflection } => {
1126 assert_eq!(reflection, "grpc://localhost:4180");
1127 }
1128 _ => panic!("expected Reflection descriptor source"),
1129 }
1130 }
1131
1132 #[test]
1133 fn test_parse_rate() {
1134 assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
1135 assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
1136 assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
1137 assert_eq!(ProxyConfig::parse_rate("invalid"), None);
1138 }
1139
1140 #[test]
1141 fn shield_rejects_unknown_field() {
1142 let yaml = r#"
1147upstream:
1148 default: "grpc://localhost:4180"
1149shield:
1150 enabled: true
1151 profiles:
1152 auth: { rate: "20/min", burst: 5 }
1153 rules:
1154 - pattern: "/v1/**"
1155 key: { type: ip }
1156 profil: "auth"
1157"#;
1158 let err = serde_yaml::from_str::<ProxyConfig>(yaml);
1159 assert!(err.is_err(), "unknown shield field must be rejected");
1160 }
1161
1162 #[test]
1163 fn shield_rejects_unknown_field_in_rule_key() {
1164 let yaml = r#"
1168upstream:
1169 default: "grpc://localhost:4180"
1170shield:
1171 enabled: true
1172 profiles:
1173 auth: { rate: "20/min", burst: 5 }
1174 rules:
1175 - pattern: "/v1/**"
1176 key: { type: ip, name: x-api-key }
1177 profile: "auth"
1178"#;
1179 let err = serde_yaml::from_str::<ProxyConfig>(yaml);
1180 assert!(err.is_err(), "unknown field in a rule key must be rejected");
1181 }
1182
1183 #[test]
1184 fn test_openapi_config_deserialize() {
1185 let yaml = r#"
1186upstream:
1187 default: "grpc://localhost:4180"
1188openapi:
1189 enabled: true
1190 path: "/api/openapi.json"
1191 docs_path: "/api/docs"
1192 title: "Test API"
1193 version: "2.0.0"
1194"#;
1195 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1196 let openapi = config.openapi.unwrap();
1197 assert!(openapi.enabled);
1198 assert_eq!(openapi.path, "/api/openapi.json");
1199 assert_eq!(openapi.docs_path, "/api/docs");
1200 assert_eq!(openapi.title.unwrap(), "Test API");
1201 assert_eq!(openapi.version.unwrap(), "2.0.0");
1202 }
1203
1204 #[test]
1205 fn test_openapi_config_defaults() {
1206 let yaml = r#"
1207upstream:
1208 default: "grpc://localhost:4180"
1209openapi:
1210 enabled: true
1211"#;
1212 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1213 let openapi = config.openapi.unwrap();
1214 assert!(openapi.enabled);
1215 assert_eq!(openapi.path, "/openapi.json");
1216 assert_eq!(openapi.docs_path, "/docs");
1217 assert!(openapi.title.is_none());
1218 assert!(openapi.version.is_none());
1219 }
1220}