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
301pub(crate) fn default_roles_claim() -> String {
304 "roles".into()
305}
306
307#[derive(Debug, Clone, Deserialize)]
309#[non_exhaustive]
310pub struct ForwardAuthConfig {
311 #[serde(default)]
312 pub enabled: bool,
313 #[serde(default = "default_forward_auth_path")]
314 pub path: String,
315 #[serde(default)]
317 pub policies: Vec<RoutePolicyConfig>,
318 #[serde(default)]
320 pub login_url: Option<String>,
321 #[serde(default)]
323 pub applications_path: Option<PathBuf>,
324}
325
326fn default_forward_auth_path() -> String {
327 "/auth/verify".into()
328}
329
330#[derive(Debug, Clone, Deserialize)]
332#[non_exhaustive]
333pub struct RoutePolicyConfig {
334 pub path: String,
335 #[serde(default = "default_methods_all")]
336 pub methods: Vec<String>,
337 #[serde(default)]
338 pub require_auth: bool,
339 #[serde(default)]
340 pub required_roles: Vec<String>,
341}
342
343fn default_methods_all() -> Vec<String> {
344 vec!["*".into()]
345}
346
347#[derive(Debug, Clone, Deserialize)]
351#[non_exhaustive]
352pub struct AuthzConfig {
353 #[serde(default)]
355 pub enabled: bool,
356 #[serde(default)]
359 pub endpoint: String,
360 #[serde(default = "default_authz_timeout_ms")]
362 pub timeout_ms: u64,
363 #[serde(default)]
366 pub failure_mode_allow: bool,
367}
368
369fn default_authz_timeout_ms() -> u64 {
370 200
371}
372
373#[derive(Debug, Clone, Deserialize)]
381#[serde(deny_unknown_fields)]
382#[non_exhaustive]
383pub struct ShieldConfig {
384 #[serde(default)]
385 pub enabled: bool,
386 #[serde(default)]
389 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
390 #[serde(default)]
393 pub rules: Vec<RateRuleConfig>,
394 #[serde(default)]
398 pub default_profile: Option<String>,
399 #[serde(default)]
402 pub jwt_limits: Option<JwtLimitConfig>,
403 #[serde(default)]
406 pub limit_service: Option<LimitServiceConfig>,
407 #[serde(default)]
410 pub sync: Option<SyncConfig>,
411 #[serde(default)]
417 pub trusted_proxies: Vec<String>,
418}
419
420#[derive(Debug, Clone, Deserialize)]
422#[serde(deny_unknown_fields)]
423#[non_exhaustive]
424pub struct LimitProfileConfig {
425 pub rate: String,
428 #[serde(default)]
431 pub burst: Option<u64>,
432}
433
434#[derive(Debug, Clone, Deserialize)]
441#[serde(deny_unknown_fields)]
442#[non_exhaustive]
443pub struct RateRuleConfig {
444 pub pattern: String,
446 #[serde(default)]
448 pub key: KeySourceConfig,
449 #[serde(default)]
452 pub profile: Option<String>,
453}
454
455#[derive(Debug, Clone, Default, PartialEq, Eq)]
460#[non_exhaustive]
461pub enum KeySourceConfig {
462 #[default]
464 Ip,
465 Header {
468 name: String,
470 },
471 JwtClaim {
474 claim: String,
476 },
477}
478
479#[derive(Deserialize)]
486#[serde(deny_unknown_fields, rename_all = "snake_case")]
487struct KeySourceRaw {
488 #[serde(rename = "type")]
489 kind: String,
490 #[serde(default)]
491 name: Option<String>,
492 #[serde(default)]
493 claim: Option<String>,
494}
495
496impl<'de> Deserialize<'de> for KeySourceConfig {
497 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
498 where
499 D: serde::Deserializer<'de>,
500 {
501 use serde::de::Error;
502 let raw = KeySourceRaw::deserialize(deserializer)?;
503 match raw.kind.as_str() {
504 "ip" => {
505 if raw.name.is_some() || raw.claim.is_some() {
506 return Err(D::Error::custom("key type 'ip' takes no other fields"));
507 }
508 Ok(Self::Ip)
509 }
510 "header" => {
511 if raw.claim.is_some() {
512 return Err(D::Error::custom(
513 "key type 'header' takes 'name', not 'claim'",
514 ));
515 }
516 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
517 Ok(Self::Header { name })
518 }
519 "jwt_claim" => {
520 if raw.name.is_some() {
521 return Err(D::Error::custom(
522 "key type 'jwt_claim' takes 'claim', not 'name'",
523 ));
524 }
525 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
526 Ok(Self::JwtClaim { claim })
527 }
528 other => Err(D::Error::unknown_variant(
529 other,
530 &["ip", "header", "jwt_claim"],
531 )),
532 }
533 }
534}
535
536#[derive(Debug, Clone, Deserialize)]
540#[serde(deny_unknown_fields)]
541#[non_exhaustive]
542pub struct JwtLimitConfig {
543 #[serde(default = "default_tier_claim")]
545 pub tier_claim: String,
546 #[serde(default = "default_rpm_claim")]
549 pub rpm_claim: String,
550 #[serde(default = "default_burst_claim")]
552 pub burst_claim: String,
553}
554
555fn default_tier_claim() -> String {
556 "ratelimit_tier".to_string()
557}
558fn default_rpm_claim() -> String {
559 "ratelimit_rpm".to_string()
560}
561fn default_burst_claim() -> String {
562 "ratelimit_burst".to_string()
563}
564
565#[derive(Debug, Clone, Deserialize)]
569#[serde(deny_unknown_fields)]
570#[non_exhaustive]
571pub struct LimitServiceConfig {
572 pub endpoint: String,
575 #[serde(default = "default_limit_ttl_secs")]
578 pub ttl_secs: u64,
579 #[serde(default = "default_limit_timeout_ms")]
581 pub timeout_ms: u64,
582}
583
584fn default_limit_ttl_secs() -> u64 {
585 300
586}
587fn default_limit_timeout_ms() -> u64 {
588 500
589}
590
591#[derive(Debug, Clone, Deserialize)]
593#[serde(deny_unknown_fields)]
594#[non_exhaustive]
595pub struct SyncConfig {
596 pub redis_url: String,
599 #[serde(default = "default_sync_interval_ms")]
602 pub interval_ms: u64,
603}
604
605fn default_sync_interval_ms() -> u64 {
606 500
607}
608
609#[derive(Debug, Clone, Deserialize)]
611#[non_exhaustive]
612pub struct OidcDiscoveryConfig {
613 #[serde(default)]
614 pub enabled: bool,
615 pub issuer: String,
616 #[serde(default)]
617 pub authorization_endpoint: Option<String>,
618 #[serde(default)]
619 pub token_endpoint: Option<String>,
620 #[serde(default)]
621 pub userinfo_endpoint: Option<String>,
622 #[serde(default)]
623 pub jwks_uri: Option<String>,
624 #[serde(default)]
625 pub signing_key: Option<SigningKeyConfig>,
626}
627
628#[derive(Debug, Clone, Deserialize)]
630#[non_exhaustive]
631pub struct SigningKeyConfig {
632 #[serde(default = "default_algorithm")]
633 pub algorithm: String,
634 pub public_key_pem_file: PathBuf,
635}
636
637fn default_algorithm() -> String {
638 "EdDSA".into()
639}
640
641#[derive(Debug, Clone, Deserialize)]
647#[non_exhaustive]
648pub struct HealthConfig {
649 #[serde(default = "default_true")]
651 pub enabled: bool,
652 #[serde(default = "default_health_path")]
654 pub path: String,
655 #[serde(default = "default_health_live_path")]
657 pub live_path: String,
658 #[serde(default = "default_health_ready_path")]
660 pub ready_path: String,
661 #[serde(default = "default_health_startup_path")]
663 pub startup_path: String,
664}
665
666fn default_health_path() -> String {
667 "/health".into()
668}
669fn default_health_live_path() -> String {
670 "/health/live".into()
671}
672fn default_health_ready_path() -> String {
673 "/health/ready".into()
674}
675fn default_health_startup_path() -> String {
676 "/health/startup".into()
677}
678
679impl Default for HealthConfig {
680 fn default() -> Self {
681 Self {
682 enabled: true,
683 path: default_health_path(),
684 live_path: default_health_live_path(),
685 ready_path: default_health_ready_path(),
686 startup_path: default_health_startup_path(),
687 }
688 }
689}
690
691#[derive(Debug, Clone, Deserialize)]
693#[non_exhaustive]
694pub struct MetricsConfig {
695 #[serde(default = "default_true")]
697 pub enabled: bool,
698 #[serde(default = "default_metrics_path")]
700 pub path: String,
701}
702
703fn default_metrics_path() -> String {
704 "/metrics".into()
705}
706
707impl Default for MetricsConfig {
708 fn default() -> Self {
709 Self {
710 enabled: true,
711 path: default_metrics_path(),
712 }
713 }
714}
715
716#[derive(Debug, Clone, Deserialize)]
718#[non_exhaustive]
719pub struct MaintenanceConfig {
720 #[serde(default)]
721 pub enabled: bool,
722 #[serde(default = "default_exempt_paths")]
724 pub exempt_paths: Vec<String>,
725 #[serde(default = "default_maintenance_message")]
726 pub message: String,
727}
728
729fn default_exempt_paths() -> Vec<String> {
730 vec![
731 "/health/**".into(),
732 "/.well-known/**".into(),
733 "/metrics".into(),
734 "/auth/verify".into(),
735 ]
736}
737
738fn default_maintenance_message() -> String {
739 "Service is under maintenance. Please try again later.".into()
740}
741
742impl Default for MaintenanceConfig {
743 fn default() -> Self {
744 Self {
745 enabled: false,
746 exempt_paths: default_exempt_paths(),
747 message: default_maintenance_message(),
748 }
749 }
750}
751
752#[derive(Debug, Clone, Default, Deserialize)]
754#[non_exhaustive]
755pub struct CorsConfig {
756 #[serde(default)]
758 pub origins: Vec<String>,
759}
760
761#[derive(Debug, Clone, Deserialize)]
763#[non_exhaustive]
764pub struct LoggingConfig {
765 #[serde(default = "default_log_level")]
766 pub level: String,
767 #[serde(default = "default_log_format")]
768 pub format: String,
769}
770
771fn default_log_level() -> String {
772 "info".into()
773}
774fn default_log_format() -> String {
775 "json".into()
776}
777
778impl Default for LoggingConfig {
779 fn default() -> Self {
780 Self {
781 level: default_log_level(),
782 format: default_log_format(),
783 }
784 }
785}
786
787#[derive(Debug, Clone, Deserialize)]
789#[non_exhaustive]
790pub struct MetricsClassConfig {
791 pub pattern: String,
793 pub class: String,
795}
796
797impl ProxyConfig {
798 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
800 Self::from_yaml_str(&std::fs::read_to_string(path)?)
801 }
802
803 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
808 let config: Self = serde_yaml::from_str(yaml)?;
809 config.validate()?;
810 Ok(config)
811 }
812
813 pub fn validate(&self) -> anyhow::Result<()> {
819 if self.streaming.sse_keep_alive_secs == 0 {
820 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
821 }
822 self.validate_edge_paths()?;
823 Ok(())
824 }
825
826 fn validate_edge_paths(&self) -> anyhow::Result<()> {
831 let mut seen = std::collections::HashSet::new();
832 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
833 if !path.starts_with('/') {
834 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
835 }
836 if !seen.insert(path.to_string()) {
837 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
838 }
839 Ok(())
840 };
841 if self.health.enabled {
842 check("health.path", &self.health.path)?;
843 check("health.live_path", &self.health.live_path)?;
844 check("health.ready_path", &self.health.ready_path)?;
845 check("health.startup_path", &self.health.startup_path)?;
846 }
847 if self.metrics.enabled {
848 check("metrics.path", &self.metrics.path)?;
849 }
850 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
851 check("openapi.path", &openapi.path)?;
852 check("openapi.docs_path", &openapi.docs_path)?;
853 }
854 Ok(())
855 }
856
857 pub fn parse_rate(rate: &str) -> Option<u32> {
859 let parts: Vec<&str> = rate.split('/').collect();
860 if parts.len() != 2 {
861 return None;
862 }
863 parts[0].trim().parse().ok()
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870
871 #[test]
872 fn test_minimal_config_deserialize() {
873 let yaml = r#"
874upstream:
875 default: "grpc://localhost:4180"
876"#;
877 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
878 assert_eq!(config.upstream.default, "grpc://localhost:4180");
879 assert_eq!(config.listen.http, "0.0.0.0:8080");
880 assert_eq!(config.service.name, "structured-proxy");
881 assert_eq!(config.streaming.sse_keep_alive_secs, 15);
882 assert!(config.descriptors.is_empty());
883 assert!(config.auth.is_none());
884 assert!(config.shield.is_none());
885 }
886
887 #[test]
888 fn health_and_metrics_defaults_and_overrides() {
889 let min: ProxyConfig =
891 serde_yaml::from_str("upstream:\n default: \"grpc://x:1\"\n").unwrap();
892 assert!(min.health.enabled);
893 assert_eq!(min.health.path, "/health");
894 assert_eq!(min.health.ready_path, "/health/ready");
895 assert!(min.metrics.enabled);
896 assert_eq!(min.metrics.path, "/metrics");
897
898 let yaml = r#"
900upstream:
901 default: "grpc://x:1"
902health:
903 path: "/internal/health"
904metrics:
905 enabled: false
906 path: "/internal/metrics"
907"#;
908 let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
909 assert_eq!(cfg.health.path, "/internal/health");
910 assert_eq!(cfg.health.live_path, "/health/live");
912 assert!(!cfg.metrics.enabled);
913 assert_eq!(cfg.metrics.path, "/internal/metrics");
914 }
915
916 #[test]
917 fn duplicate_probe_paths_are_rejected() {
918 let yaml = r#"
921upstream:
922 default: "grpc://x:1"
923health:
924 path: "/health/live"
925"#;
926 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
927 assert!(err.to_string().contains("duplicate endpoint path"));
928
929 let yaml2 = r#"
931upstream:
932 default: "grpc://x:1"
933metrics:
934 path: "/health"
935"#;
936 let err2 = ProxyConfig::from_yaml_str(yaml2).unwrap_err();
937 assert!(err2.to_string().contains("duplicate endpoint path"));
938
939 let yaml3 = r#"
941upstream:
942 default: "grpc://x:1"
943health:
944 enabled: false
945 path: "/metrics"
946"#;
947 assert!(ProxyConfig::from_yaml_str(yaml3).is_ok());
948 }
949
950 #[test]
951 fn malformed_edge_path_is_rejected() {
952 let yaml = r#"
955upstream:
956 default: "grpc://x:1"
957health:
958 path: "health"
959"#;
960 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
961 assert!(err.to_string().contains("must start with '/'"));
962 }
963
964 #[test]
965 fn test_zero_sse_keep_alive_is_rejected() {
966 let yaml = r#"
969upstream:
970 default: "grpc://localhost:4180"
971streaming:
972 sse_keep_alive_secs: 0
973"#;
974 let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
975 assert!(err.to_string().contains("sse_keep_alive_secs"));
976 }
977
978 #[test]
979 fn test_full_config_deserialize() {
980 let yaml = r#"
981upstream:
982 default: "grpc://sid-identity:4180"
983
984descriptors:
985 - file: "/etc/proxy/sid.descriptor.bin"
986
987listen:
988 http: "0.0.0.0:9090"
989
990service:
991 name: "sid-proxy"
992
993aliases:
994 - from: "/oauth2/{path}"
995 to: "/v1/oauth2/{path}"
996
997auth:
998 mode: "jwt"
999 jwt:
1000 issuer: "https://auth.example.com"
1001 public_key_pem_file: "/etc/proxy/signing.pub"
1002 claims_headers:
1003 sub: "x-forwarded-user"
1004 acr: "x-sid-auth-level"
1005 forward_auth:
1006 enabled: true
1007 path: "/auth/verify"
1008 policies:
1009 - path: "/v1/admin/**"
1010 require_auth: true
1011 required_roles: ["admin"]
1012 - path: "/v1/public/**"
1013 require_auth: false
1014 authz:
1015 enabled: true
1016 endpoint: "http://opa:9191" # Envoy ext_authz server (gRPC)
1017 timeout_ms: 200
1018 failure_mode_allow: false # fail closed: deny if authz is unreachable
1019
1020shield:
1021 enabled: true
1022 profiles:
1023 auth: { rate: "20/min", burst: 5 }
1024 default: { rate: "100/min" }
1025 premium: { rate: "1000/min", burst: 50 }
1026 default_profile: "default"
1027 jwt_limits:
1028 tier_claim: "ratelimit_tier"
1029 rules:
1030 - pattern: "/v1/auth/**"
1031 key: { type: ip }
1032 profile: "auth"
1033 - pattern: "/v1/**"
1034 key: { type: jwt_claim, claim: "sub" }
1035 trusted_proxies: ["10.0.0.0/8"]
1036
1037oidc_discovery:
1038 enabled: true
1039 issuer: "https://auth.example.com"
1040
1041maintenance:
1042 enabled: false
1043 exempt_paths:
1044 - "/health/**"
1045 - "/.well-known/**"
1046
1047cors:
1048 origins:
1049 - "https://app.example.com"
1050
1051metrics_classes:
1052 - pattern: "/v1/auth/**"
1053 class: "auth"
1054 - pattern: "/v1/admin/**"
1055 class: "admin"
1056
1057forwarded_headers:
1058 - "authorization"
1059 - "dpop"
1060 - "x-request-id"
1061"#;
1062 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1063 assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
1064 assert_eq!(config.listen.http, "0.0.0.0:9090");
1065 assert_eq!(config.service.name, "sid-proxy");
1066 assert_eq!(config.aliases.len(), 1);
1067 assert!(config.auth.is_some());
1068 let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
1069 assert!(authz.enabled);
1070 assert_eq!(authz.endpoint, "http://opa:9191");
1071 assert_eq!(authz.timeout_ms, 200);
1072 assert!(!authz.failure_mode_allow);
1073 assert!(config.shield.is_some());
1074 assert!(config.oidc_discovery.is_some());
1075 assert_eq!(config.cors.origins.len(), 1);
1076 assert_eq!(config.metrics_classes.len(), 2);
1077 assert_eq!(config.forwarded_headers.len(), 3);
1078 }
1079
1080 #[test]
1081 fn authz_disabled_without_endpoint_parses() {
1082 let yaml = r#"
1084upstream:
1085 default: "grpc://localhost:4180"
1086descriptors:
1087 - file: "/x.bin"
1088auth:
1089 mode: "jwt"
1090 authz:
1091 enabled: false
1092"#;
1093 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1094 let authz = config.auth.unwrap().authz.unwrap();
1095 assert!(!authz.enabled);
1096 assert_eq!(authz.endpoint, "");
1097 }
1098
1099 #[test]
1100 fn test_descriptor_source_file() {
1101 let yaml = r#"
1102upstream:
1103 default: "grpc://localhost:4180"
1104descriptors:
1105 - file: "/etc/proxy/service.descriptor.bin"
1106"#;
1107 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1108 assert_eq!(config.descriptors.len(), 1);
1109 match &config.descriptors[0] {
1110 DescriptorSource::File { file } => {
1111 assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
1112 }
1113 _ => panic!("expected File descriptor source"),
1114 }
1115 }
1116
1117 #[test]
1118 fn test_descriptor_source_reflection() {
1119 let yaml = r#"
1120upstream:
1121 default: "grpc://localhost:4180"
1122descriptors:
1123 - reflection: "grpc://localhost:4180"
1124"#;
1125 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1126 match &config.descriptors[0] {
1127 DescriptorSource::Reflection { reflection } => {
1128 assert_eq!(reflection, "grpc://localhost:4180");
1129 }
1130 _ => panic!("expected Reflection descriptor source"),
1131 }
1132 }
1133
1134 #[test]
1135 fn test_parse_rate() {
1136 assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
1137 assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
1138 assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
1139 assert_eq!(ProxyConfig::parse_rate("invalid"), None);
1140 }
1141
1142 #[test]
1143 fn shield_rejects_unknown_field() {
1144 let yaml = r#"
1149upstream:
1150 default: "grpc://localhost:4180"
1151shield:
1152 enabled: true
1153 profiles:
1154 auth: { rate: "20/min", burst: 5 }
1155 rules:
1156 - pattern: "/v1/**"
1157 key: { type: ip }
1158 profil: "auth"
1159"#;
1160 let err = serde_yaml::from_str::<ProxyConfig>(yaml);
1161 assert!(err.is_err(), "unknown shield field must be rejected");
1162 }
1163
1164 #[test]
1165 fn shield_rejects_unknown_field_in_rule_key() {
1166 let yaml = r#"
1170upstream:
1171 default: "grpc://localhost:4180"
1172shield:
1173 enabled: true
1174 profiles:
1175 auth: { rate: "20/min", burst: 5 }
1176 rules:
1177 - pattern: "/v1/**"
1178 key: { type: ip, name: x-api-key }
1179 profile: "auth"
1180"#;
1181 let err = serde_yaml::from_str::<ProxyConfig>(yaml);
1182 assert!(err.is_err(), "unknown field in a rule key must be rejected");
1183 }
1184
1185 #[test]
1186 fn test_openapi_config_deserialize() {
1187 let yaml = r#"
1188upstream:
1189 default: "grpc://localhost:4180"
1190openapi:
1191 enabled: true
1192 path: "/api/openapi.json"
1193 docs_path: "/api/docs"
1194 title: "Test API"
1195 version: "2.0.0"
1196"#;
1197 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1198 let openapi = config.openapi.unwrap();
1199 assert!(openapi.enabled);
1200 assert_eq!(openapi.path, "/api/openapi.json");
1201 assert_eq!(openapi.docs_path, "/api/docs");
1202 assert_eq!(openapi.title.unwrap(), "Test API");
1203 assert_eq!(openapi.version.unwrap(), "2.0.0");
1204 }
1205
1206 #[test]
1207 fn test_openapi_config_defaults() {
1208 let yaml = r#"
1209upstream:
1210 default: "grpc://localhost:4180"
1211openapi:
1212 enabled: true
1213"#;
1214 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
1215 let openapi = config.openapi.unwrap();
1216 assert!(openapi.enabled);
1217 assert_eq!(openapi.path, "/openapi.json");
1218 assert_eq!(openapi.docs_path, "/docs");
1219 assert!(openapi.title.is_none());
1220 assert!(openapi.version.is_none());
1221 }
1222}