Skip to main content

structured_proxy/
config.rs

1//! YAML-based proxy configuration.
2//!
3//! All product-specific behavior is driven by config, not code.
4//! Same binary, different YAML = different product proxy.
5
6use serde::Deserialize;
7use std::path::PathBuf;
8
9/// Top-level proxy configuration (loaded from YAML).
10#[derive(Debug, Clone, Deserialize)]
11#[non_exhaustive]
12pub struct ProxyConfig {
13    /// Upstream gRPC service(s).
14    pub upstream: UpstreamConfig,
15
16    /// Proto descriptor sources.
17    #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
18    pub descriptors: Vec<DescriptorSource>,
19
20    /// Listen addresses.
21    #[serde(default)]
22    pub listen: ListenConfig,
23
24    /// Service identity (for health endpoint, metrics namespace).
25    #[serde(default)]
26    pub service: ServiceConfig,
27
28    /// Path aliases (e.g., /oauth2/* → /v1/oauth2/*).
29    #[serde(default)]
30    pub aliases: Vec<AliasConfig>,
31
32    /// OpenAPI generation.
33    #[serde(default)]
34    pub openapi: Option<OpenApiConfig>,
35
36    /// Auth configuration (JWT, forward auth, AuthZ).
37    #[serde(default)]
38    pub auth: Option<AuthConfig>,
39
40    /// Rate limiting (Shield).
41    #[serde(default)]
42    pub shield: Option<ShieldConfig>,
43
44    /// OIDC discovery (optional — for IdP proxies).
45    #[serde(default)]
46    pub oidc_discovery: Option<OidcDiscoveryConfig>,
47
48    /// Maintenance mode.
49    #[serde(default)]
50    pub maintenance: MaintenanceConfig,
51
52    /// CORS configuration.
53    #[serde(default)]
54    pub cors: CorsConfig,
55
56    /// Logging.
57    #[serde(default)]
58    pub logging: LoggingConfig,
59
60    /// Metrics endpoint classification (path patterns → class labels).
61    #[serde(default)]
62    pub metrics_classes: Vec<MetricsClassConfig>,
63
64    /// Headers to forward from HTTP to gRPC metadata.
65    #[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/// Upstream gRPC service configuration.
84#[derive(Debug, Clone, Deserialize)]
85#[non_exhaustive]
86pub struct UpstreamConfig {
87    /// gRPC upstream address (e.g., "http://localhost:4180").
88    pub default: String,
89}
90
91/// Descriptor loading source.
92#[derive(Debug, Clone)]
93#[non_exhaustive]
94pub enum DescriptorSource {
95    /// Pre-compiled descriptor file.
96    File { file: PathBuf },
97    /// gRPC server reflection (development mode).
98    Reflection { reflection: String },
99    /// Embedded bytes (set programmatically, not from YAML).
100    Embedded { bytes: &'static [u8] },
101}
102
103/// Helper for YAML deserialization (only File and Reflection variants).
104#[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/// Listen address configuration.
133#[derive(Debug, Clone, Deserialize)]
134#[non_exhaustive]
135pub struct ListenConfig {
136    /// HTTP listen address (default: "0.0.0.0:8080").
137    #[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/// Service identity.
154#[derive(Debug, Clone, Deserialize)]
155#[non_exhaustive]
156pub struct ServiceConfig {
157    /// Service name (appears in /health response and metrics namespace).
158    #[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/// Path alias (rewrite before routing).
175#[derive(Debug, Clone, Deserialize)]
176#[non_exhaustive]
177pub struct AliasConfig {
178    pub from: String,
179    pub to: String,
180}
181
182/// OpenAPI generation config.
183#[derive(Debug, Clone, Deserialize)]
184#[non_exhaustive]
185pub struct OpenApiConfig {
186    #[serde(default = "default_true")]
187    pub enabled: bool,
188    /// Path for OpenAPI JSON spec (default: "/openapi.json").
189    #[serde(default = "default_openapi_path")]
190    pub path: String,
191    /// Path for interactive API docs UI (default: "/docs").
192    #[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/// Auth configuration.
213#[derive(Debug, Clone, Deserialize)]
214#[non_exhaustive]
215pub struct AuthConfig {
216    /// Auth mode: "none", "jwt", "api_key".
217    #[serde(default = "default_auth_mode")]
218    pub mode: String,
219
220    /// JWT validation config.
221    #[serde(default)]
222    pub jwt: Option<JwtConfig>,
223
224    /// Forward auth endpoint.
225    #[serde(default)]
226    pub forward_auth: Option<ForwardAuthConfig>,
227
228    /// AuthZ integration (optional gRPC call).
229    #[serde(default)]
230    pub authz: Option<AuthzConfig>,
231}
232
233fn default_auth_mode() -> String {
234    "none".into()
235}
236
237/// JWT validation config.
238#[derive(Debug, Clone, Deserialize)]
239#[non_exhaustive]
240pub struct JwtConfig {
241    /// JWKS URI for key discovery.
242    #[serde(default)]
243    pub jwks_uri: Option<String>,
244    /// Expected issuer.
245    #[serde(default)]
246    pub issuer: Option<String>,
247    /// Expected audience.
248    #[serde(default)]
249    pub audience: Option<String>,
250    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
251    #[serde(default)]
252    pub public_key_pem_file: Option<PathBuf>,
253    /// Claims → HTTP headers mapping.
254    #[serde(default)]
255    pub claims_headers: std::collections::HashMap<String, String>,
256    /// Claim holding the user's roles (array of strings). Supports a dotted
257    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
258    #[serde(default = "default_roles_claim")]
259    pub roles_claim: String,
260}
261
262fn default_roles_claim() -> String {
263    "roles".into()
264}
265
266/// Forward auth config.
267#[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    /// Route policies.
275    #[serde(default)]
276    pub policies: Vec<RoutePolicyConfig>,
277    /// Login URL for 401 redirects.
278    #[serde(default)]
279    pub login_url: Option<String>,
280    /// Applications YAML file path.
281    #[serde(default)]
282    pub applications_path: Option<PathBuf>,
283}
284
285fn default_forward_auth_path() -> String {
286    "/auth/verify".into()
287}
288
289/// Route policy entry.
290#[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/// External authorization via the Envoy ext_authz gRPC contract
307/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
308/// ext_authz server.
309#[derive(Debug, Clone, Deserialize)]
310#[non_exhaustive]
311pub struct AuthzConfig {
312    /// Enable external authorization for proxied API requests.
313    #[serde(default)]
314    pub enabled: bool,
315    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
316    /// when enabled; defaults to empty so a disabled block can omit it.
317    #[serde(default)]
318    pub endpoint: String,
319    /// Per-request authorization call timeout, in milliseconds.
320    #[serde(default = "default_authz_timeout_ms")]
321    pub timeout_ms: u64,
322    /// When the authz call itself fails (unreachable / timeout), allow the
323    /// request through instead of denying. Defaults to false (fail closed).
324    #[serde(default)]
325    pub failure_mode_allow: bool,
326}
327
328fn default_authz_timeout_ms() -> u64 {
329    200
330}
331
332/// Shield (rate limiting) configuration.
333#[derive(Debug, Clone, Deserialize)]
334#[non_exhaustive]
335pub struct ShieldConfig {
336    #[serde(default)]
337    pub enabled: bool,
338    /// Endpoint classification (glob pattern → class → rate limit).
339    #[serde(default)]
340    pub endpoint_classes: Vec<EndpointClassConfig>,
341    /// Per-identifier rate limiting.
342    #[serde(default)]
343    pub identifier_endpoints: Vec<IdentifierEndpointConfig>,
344    /// Window size in seconds (default: 60).
345    #[serde(default = "default_window_secs")]
346    pub window_secs: u64,
347    /// Redis URL for shared counters across replicas (e.g. "redis://127.0.0.1/").
348    /// When unset, an in-process per-replica store is used. Requires the `redis`
349    /// build feature; otherwise the proxy logs a warning and uses the in-process
350    /// store.
351    #[serde(default)]
352    pub redis_url: Option<String>,
353    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
354    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
355    /// direct peer falls in one of these ranges; otherwise the peer socket
356    /// address is used as the client identity. Empty (the default) means do not
357    /// trust forwarding headers — set this behind a load balancer.
358    #[serde(default)]
359    pub trusted_proxies: Vec<String>,
360}
361
362fn default_window_secs() -> u64 {
363    60
364}
365
366/// Endpoint classification for rate limiting.
367#[derive(Debug, Clone, Deserialize)]
368#[non_exhaustive]
369pub struct EndpointClassConfig {
370    /// Glob pattern (e.g., "/v1/auth/**").
371    pub pattern: String,
372    /// Class name (e.g., "auth").
373    pub class: String,
374    /// Rate limit string (e.g., "20/min").
375    pub rate: String,
376}
377
378/// Per-identifier rate limiting config.
379#[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/// OIDC discovery config.
388#[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/// Signing key config for JWKS endpoint.
407#[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/// Maintenance mode config.
420#[derive(Debug, Clone, Deserialize)]
421#[non_exhaustive]
422pub struct MaintenanceConfig {
423    #[serde(default)]
424    pub enabled: bool,
425    /// Paths exempt from maintenance mode (glob patterns).
426    #[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/// CORS configuration.
456#[derive(Debug, Clone, Default, Deserialize)]
457#[non_exhaustive]
458pub struct CorsConfig {
459    /// Allowed origins. Empty = permissive (dev mode).
460    #[serde(default)]
461    pub origins: Vec<String>,
462}
463
464/// Logging configuration.
465#[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/// Metrics endpoint classification.
491#[derive(Debug, Clone, Deserialize)]
492#[non_exhaustive]
493pub struct MetricsClassConfig {
494    /// Glob pattern for path matching.
495    pub pattern: String,
496    /// Label value for this class.
497    pub class: String,
498}
499
500impl ProxyConfig {
501    /// Load configuration from a YAML file.
502    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    /// Parse rate string like "20/min" → requests per window.
509    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        // A disabled authz block need not supply an endpoint.
639        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}