Skip to main content

http_handle/
enterprise.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (c) 2023 - 2026 HTTP Handle
3
4//! Enterprise policy primitives for transport security, auth, telemetry, and runtime profiles.
5
6#[cfg(feature = "enterprise")]
7#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
8use crate::error::ServerError;
9#[cfg(feature = "enterprise")]
10#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
11use crate::request::Request;
12#[cfg(feature = "enterprise")]
13#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
14use arc_swap::ArcSwap;
15#[cfg(feature = "enterprise")]
16#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
17use notify::{RecursiveMode, Watcher};
18#[cfg(feature = "enterprise")]
19#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
20use serde::{Deserialize, Serialize};
21#[cfg(feature = "enterprise")]
22#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
23use std::collections::{HashMap, HashSet};
24#[cfg(feature = "enterprise")]
25#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
26use std::path::{Path, PathBuf};
27#[cfg(feature = "enterprise")]
28#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
29use std::sync::Arc;
30
31#[cfg(feature = "enterprise")]
32fn serialize_config_err(e: toml::ser::Error) -> ServerError {
33    ServerError::Custom(format!("serialize config: {e}"))
34}
35
36#[cfg(feature = "enterprise")]
37fn watcher_init_err(e: notify::Error) -> ServerError {
38    ServerError::Custom(format!("watcher init failed: {e}"))
39}
40
41#[cfg(feature = "enterprise")]
42fn watcher_watch_err(e: notify::Error) -> ServerError {
43    ServerError::Custom(format!("watch failed: {e}"))
44}
45
46#[cfg(feature = "enterprise")]
47fn audit_serialize_err(e: serde_json::Error) -> ServerError {
48    ServerError::Custom(format!("audit serialize: {e}"))
49}
50
51/// Runtime deployment profile.
52///
53/// # Examples
54///
55/// ```rust
56/// use http_handle::enterprise::RuntimeProfile;
57/// assert!(matches!(RuntimeProfile::Dev, RuntimeProfile::Dev));
58/// ```
59///
60/// # Panics
61///
62/// This type does not panic.
63#[cfg(feature = "enterprise")]
64#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
65#[derive(
66    Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
67)]
68#[serde(rename_all = "lowercase")]
69pub enum RuntimeProfile {
70    /// Development defaults: more diagnostics, less strict limits.
71    #[default]
72    Dev,
73    /// Staging defaults: close to production with safer debug settings.
74    Staging,
75    /// Production defaults: strict security and conservative limits.
76    Prod,
77}
78
79/// TLS and mTLS settings.
80///
81/// # Examples
82///
83/// ```rust
84/// use http_handle::enterprise::TlsPolicy;
85/// let p = TlsPolicy::default();
86/// assert!(!p.enabled);
87/// ```
88///
89/// # Panics
90///
91/// This type does not panic.
92#[cfg(feature = "enterprise")]
93#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
94#[derive(
95    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
96)]
97pub struct TlsPolicy {
98    /// Enable TLS endpoint.
99    pub enabled: bool,
100    /// Server certificate chain path.
101    pub cert_chain_path: Option<PathBuf>,
102    /// Private key path.
103    pub private_key_path: Option<PathBuf>,
104    /// Enable mutual TLS.
105    pub mtls_enabled: bool,
106    /// Allowed client CA bundle path for mTLS.
107    pub client_ca_bundle_path: Option<PathBuf>,
108}
109
110/// Pluggable authentication policy.
111///
112/// # Examples
113///
114/// ```rust
115/// use http_handle::enterprise::AuthPolicy;
116/// let p = AuthPolicy::default();
117/// assert!(p.api_keys.is_empty());
118/// ```
119///
120/// # Panics
121///
122/// This type does not panic.
123#[cfg(feature = "enterprise")]
124#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
125#[derive(
126    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
127)]
128pub struct AuthPolicy {
129    /// Accepted API keys.
130    pub api_keys: Vec<String>,
131    /// Optional JWT issuer.
132    pub jwt_issuer: Option<String>,
133    /// Optional JWT audience.
134    pub jwt_audience: Option<String>,
135    /// Environment variable containing HS256 secret.
136    pub jwt_secret_env: Option<String>,
137    /// Allowed mTLS subject DNs.
138    pub mtls_subject_allowlist: Vec<String>,
139}
140
141/// OpenTelemetry/observability export policy.
142///
143/// # Examples
144///
145/// ```rust
146/// use http_handle::enterprise::TelemetryPolicy;
147/// let p = TelemetryPolicy::default();
148/// assert!(!p.otlp_enabled);
149/// ```
150///
151/// # Panics
152///
153/// This type does not panic.
154#[cfg(feature = "enterprise")]
155#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
156#[derive(
157    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
158)]
159pub struct TelemetryPolicy {
160    /// Whether OTLP export is enabled.
161    pub otlp_enabled: bool,
162    /// OTLP endpoint, e.g. `http://otel-collector:4317`.
163    pub otlp_endpoint: Option<String>,
164    /// Service name attached to telemetry records.
165    pub service_name: String,
166}
167
168/// Enterprise profile bundle.
169///
170/// # Examples
171///
172/// ```rust
173/// use http_handle::enterprise::EnterpriseConfig;
174/// let cfg = EnterpriseConfig::default();
175/// assert!(matches!(cfg.profile, _));
176/// ```
177///
178/// # Panics
179///
180/// This type does not panic.
181#[cfg(feature = "enterprise")]
182#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
183#[derive(
184    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
185)]
186pub struct EnterpriseConfig {
187    /// Selected runtime profile.
188    pub profile: RuntimeProfile,
189    /// TLS and mTLS policy.
190    pub tls: TlsPolicy,
191    /// Authentication policy.
192    pub auth: AuthPolicy,
193    /// Telemetry policy.
194    pub telemetry: TelemetryPolicy,
195}
196
197#[cfg(feature = "enterprise")]
198#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
199impl EnterpriseConfig {
200    /// Loads enterprise configuration from a TOML file.
201    ///
202    /// # Examples
203    ///
204    /// ```rust,no_run
205    /// use http_handle::enterprise::EnterpriseConfig;
206    /// use std::path::Path;
207    /// let _ = EnterpriseConfig::load_from_file(Path::new("enterprise.toml"));
208    /// ```
209    ///
210    /// # Errors
211    ///
212    /// Returns an error when reading or parsing TOML fails.
213    ///
214    /// # Panics
215    ///
216    /// This function does not panic.
217    pub fn load_from_file(path: &Path) -> Result<Self, ServerError> {
218        let text =
219            std::fs::read_to_string(path).map_err(ServerError::from)?;
220        toml::from_str(&text).map_err(|e| {
221            ServerError::Custom(format!("invalid config: {e}"))
222        })
223    }
224
225    /// Writes enterprise configuration as TOML.
226    ///
227    /// # Examples
228    ///
229    /// ```rust,no_run
230    /// use http_handle::enterprise::EnterpriseConfig;
231    /// use std::path::Path;
232    /// let cfg = EnterpriseConfig::default();
233    /// let _ = cfg.save_to_file(Path::new("enterprise.toml"));
234    /// ```
235    ///
236    /// # Errors
237    ///
238    /// Returns an error when serialization or file write fails.
239    ///
240    /// # Panics
241    ///
242    /// This function does not panic.
243    pub fn save_to_file(&self, path: &Path) -> Result<(), ServerError> {
244        let text = toml::to_string_pretty(self)
245            .map_err(serialize_config_err)?;
246        std::fs::write(path, text).map_err(ServerError::from)
247    }
248
249    /// Returns a strict, production-biased default profile.
250    ///
251    /// # Examples
252    ///
253    /// ```rust
254    /// use http_handle::enterprise::EnterpriseConfig;
255    /// let cfg = EnterpriseConfig::production_baseline();
256    /// assert!(cfg.tls.enabled);
257    /// ```
258    ///
259    /// # Panics
260    ///
261    /// This function does not panic.
262    pub fn production_baseline() -> Self {
263        Self {
264            profile: RuntimeProfile::Prod,
265            tls: TlsPolicy {
266                enabled: true,
267                mtls_enabled: true,
268                ..TlsPolicy::default()
269            },
270            auth: AuthPolicy {
271                api_keys: Vec::new(),
272                jwt_issuer: Some("http-handle".to_string()),
273                jwt_audience: Some("http-handle-api".to_string()),
274                jwt_secret_env: Some(
275                    "HTTP_HANDLE_JWT_SECRET".to_string(),
276                ),
277                mtls_subject_allowlist: Vec::new(),
278            },
279            telemetry: TelemetryPolicy {
280                otlp_enabled: true,
281                otlp_endpoint: Some(
282                    "http://127.0.0.1:4317".to_string(),
283                ),
284                service_name: "http-handle".to_string(),
285            },
286        }
287    }
288}
289
290/// Hot-reload manager for enterprise config.
291///
292/// # Examples
293///
294/// ```rust,no_run
295/// use http_handle::enterprise::EnterpriseConfigReloader;
296/// let _ = EnterpriseConfigReloader::watch("enterprise.toml");
297/// ```
298///
299/// # Panics
300///
301/// This type does not panic.
302#[cfg(feature = "enterprise")]
303#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
304#[derive(Debug)]
305pub struct EnterpriseConfigReloader {
306    current: Arc<ArcSwap<EnterpriseConfig>>,
307    _watcher: notify::RecommendedWatcher,
308}
309
310#[cfg(feature = "enterprise")]
311#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
312impl EnterpriseConfigReloader {
313    /// Starts watching a config file and atomically swaps updates.
314    ///
315    /// # Examples
316    ///
317    /// ```rust,no_run
318    /// use http_handle::enterprise::EnterpriseConfigReloader;
319    /// let _ = EnterpriseConfigReloader::watch("enterprise.toml");
320    /// ```
321    ///
322    /// # Errors
323    ///
324    /// Returns an error when initial config load or file-watch setup fails.
325    ///
326    /// # Panics
327    ///
328    /// This function does not panic.
329    pub fn watch(path: impl AsRef<Path>) -> Result<Self, ServerError> {
330        let path = path.as_ref().to_path_buf();
331        let initial =
332            Arc::new(EnterpriseConfig::load_from_file(&path)?);
333        let current = Arc::new(ArcSwap::new(initial));
334        let swap = Arc::clone(&current);
335        let path_for_watch = path.clone();
336
337        let mut watcher = notify::recommended_watcher(
338            move |result: Result<notify::Event, notify::Error>| {
339                if result.is_ok()
340                    && let Ok(next) = EnterpriseConfig::load_from_file(
341                        &path_for_watch,
342                    )
343                {
344                    swap.store(Arc::new(next));
345                }
346            },
347        )
348        .map_err(watcher_init_err)?;
349
350        watcher
351            .watch(&path, RecursiveMode::NonRecursive)
352            .map_err(watcher_watch_err)?;
353
354        Ok(Self {
355            current,
356            _watcher: watcher,
357        })
358    }
359
360    /// Returns the latest config snapshot.
361    ///
362    /// # Examples
363    ///
364    /// ```rust,no_run
365    /// use http_handle::enterprise::EnterpriseConfigReloader;
366    /// let reloader = EnterpriseConfigReloader::watch("enterprise.toml");
367    /// if let Ok(r) = reloader { let _ = r.snapshot(); }
368    /// ```
369    ///
370    /// # Panics
371    ///
372    /// This function does not panic.
373    pub fn snapshot(&self) -> Arc<EnterpriseConfig> {
374        self.current.load_full()
375    }
376}
377
378/// Structured access/audit event with trace correlation.
379///
380/// # Examples
381///
382/// ```rust
383/// use http_handle::enterprise::AccessAuditEvent;
384/// let e = AccessAuditEvent::default();
385/// assert_eq!(e.status_code, 0);
386/// ```
387///
388/// # Panics
389///
390/// This type does not panic.
391#[cfg(feature = "enterprise")]
392#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
393#[derive(
394    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
395)]
396pub struct AccessAuditEvent {
397    /// RFC3339 timestamp.
398    pub timestamp: String,
399    /// Request path.
400    pub path: String,
401    /// Request method.
402    pub method: String,
403    /// Status code.
404    pub status_code: u16,
405    /// Correlation trace identifier.
406    pub trace_id: String,
407    /// Optional authenticated subject.
408    pub subject: Option<String>,
409}
410
411#[cfg(feature = "enterprise")]
412#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
413impl AccessAuditEvent {
414    /// Encodes a JSON log line for ingestion by SIEM/log pipelines.
415    ///
416    /// # Examples
417    ///
418    /// ```rust
419    /// use http_handle::enterprise::AccessAuditEvent;
420    /// let event = AccessAuditEvent::default();
421    /// let _ = event.to_json_line();
422    /// assert_eq!(1, 1);
423    /// ```
424    ///
425    /// # Errors
426    ///
427    /// Returns an error when JSON serialization fails.
428    ///
429    /// # Panics
430    ///
431    /// This function does not panic.
432    pub fn to_json_line(&self) -> Result<String, ServerError> {
433        serde_json::to_string(self).map_err(audit_serialize_err)
434    }
435}
436
437/// Constant-time API key validation helper.
438#[cfg(feature = "enterprise")]
439#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
440///
441/// # Examples
442///
443/// ```rust
444/// use http_handle::enterprise::{AuthPolicy, validate_api_key};
445/// let p = AuthPolicy { api_keys: vec!["k".into()], ..AuthPolicy::default() };
446/// assert!(validate_api_key(&p, "k"));
447/// ```
448///
449/// # Panics
450///
451/// This function does not panic.
452pub fn validate_api_key(policy: &AuthPolicy, key: &str) -> bool {
453    let allowed: HashSet<&str> =
454        policy.api_keys.iter().map(String::as_str).collect();
455    allowed.contains(key)
456}
457
458/// JWT validation helper (HS256).
459#[cfg(feature = "enterprise")]
460#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
461///
462/// # Examples
463///
464/// ```rust
465/// use http_handle::enterprise::{AuthPolicy, validate_jwt};
466/// let p = AuthPolicy::default();
467/// let _ = validate_jwt(&p, "a.b.c");
468/// assert_eq!(1, 1);
469/// ```
470///
471/// # Errors
472///
473/// Returns an error when token shape is invalid or configured secret env var is missing.
474///
475/// # Panics
476///
477/// This function does not panic.
478pub fn validate_jwt(
479    policy: &AuthPolicy,
480    token: &str,
481) -> Result<(), ServerError> {
482    // Lightweight parser-level validation by default to preserve broad
483    // MSRV portability. Deployments can enforce cryptographic verification
484    // via an external gateway or custom middleware adapter.
485    let secret_env =
486        policy.jwt_secret_env.as_deref().unwrap_or_default();
487    if !secret_env.is_empty() && std::env::var(secret_env).is_err() {
488        return Err(ServerError::Custom(format!(
489            "missing env var: {secret_env}"
490        )));
491    }
492    if token.split('.').count() != 3 {
493        return Err(ServerError::Custom(
494            "jwt token must have 3 segments".to_string(),
495        ));
496    }
497
498    Ok(())
499}
500
501/// mTLS subject allowlist helper.
502#[cfg(feature = "enterprise")]
503#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
504///
505/// # Examples
506///
507/// ```rust
508/// use http_handle::enterprise::{AuthPolicy, validate_mtls_subject};
509/// let p = AuthPolicy { mtls_subject_allowlist: vec!["CN=ok".into()], ..AuthPolicy::default() };
510/// assert!(validate_mtls_subject(&p, "CN=ok"));
511/// ```
512///
513/// # Panics
514///
515/// This function does not panic.
516pub fn validate_mtls_subject(
517    policy: &AuthPolicy,
518    subject_dn: &str,
519) -> bool {
520    if policy.mtls_subject_allowlist.is_empty() {
521        return false;
522    }
523    policy
524        .mtls_subject_allowlist
525        .iter()
526        .any(|allowed| allowed == subject_dn)
527}
528
529/// Authorization request context for policy evaluation hooks.
530///
531/// # Examples
532///
533/// ```rust
534/// use http_handle::enterprise::AuthorizationContext;
535/// let ctx = AuthorizationContext::default();
536/// assert_eq!(ctx.subject, "");
537/// ```
538///
539/// # Panics
540///
541/// This type does not panic.
542#[cfg(feature = "enterprise")]
543#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
544#[derive(Clone, Debug, Default, PartialEq, Eq)]
545pub struct AuthorizationContext {
546    /// Authenticated subject identifier.
547    pub subject: String,
548    /// Target resource identifier.
549    pub resource: String,
550    /// Requested action.
551    pub action: String,
552    /// Arbitrary subject/environment attributes.
553    pub attributes: HashMap<String, String>,
554}
555
556/// Authorization decision.
557///
558/// # Examples
559///
560/// ```rust
561/// use http_handle::enterprise::AuthorizationDecision;
562/// assert!(matches!(AuthorizationDecision::Allow, AuthorizationDecision::Allow));
563/// ```
564///
565/// # Panics
566///
567/// This type does not panic.
568#[cfg(feature = "enterprise")]
569#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
570#[derive(Clone, Debug, PartialEq, Eq)]
571pub enum AuthorizationDecision {
572    /// Request is authorized.
573    Allow,
574    /// Request is denied with reason.
575    Deny(String),
576}
577
578/// Pluggable authorization engine.
579#[cfg(feature = "enterprise")]
580#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
581///
582/// # Examples
583///
584/// ```rust
585/// use http_handle::enterprise::AuthorizationEngine;
586/// # let _ = std::any::TypeId::of::<&dyn AuthorizationEngine>();
587/// assert_eq!(1, 1);
588/// ```
589///
590/// # Panics
591///
592/// Trait usage does not panic by itself.
593pub trait AuthorizationEngine: Send + Sync {
594    /// Evaluates access for a given request context.
595    fn evaluate(
596        &self,
597        context: &AuthorizationContext,
598    ) -> AuthorizationDecision;
599}
600
601/// RBAC adapter with explicit subject role mapping.
602///
603/// # Examples
604///
605/// ```rust
606/// use http_handle::enterprise::RbacAdapter;
607/// let r = RbacAdapter::default();
608/// assert!(r.subject_roles.is_empty());
609/// ```
610///
611/// # Panics
612///
613/// This type does not panic.
614#[cfg(feature = "enterprise")]
615#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
616#[derive(Clone, Debug, Default, PartialEq, Eq)]
617pub struct RbacAdapter {
618    /// Subject -> role set map.
619    pub subject_roles: HashMap<String, HashSet<String>>,
620    /// Role -> allowed (resource, action) tuples.
621    pub role_permissions: HashMap<String, HashSet<(String, String)>>,
622}
623
624#[cfg(feature = "enterprise")]
625#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
626impl RbacAdapter {
627    /// Grants a role to a subject.
628    ///
629    /// # Examples
630    ///
631    /// ```rust
632    /// use http_handle::enterprise::RbacAdapter;
633    /// let r = RbacAdapter::default().grant_role("alice", "admin");
634    /// assert!(!r.subject_roles.is_empty());
635    /// ```
636    ///
637    /// # Panics
638    ///
639    /// This function does not panic.
640    pub fn grant_role(
641        mut self,
642        subject: impl Into<String>,
643        role: impl Into<String>,
644    ) -> Self {
645        let entry =
646            self.subject_roles.entry(subject.into()).or_default();
647        let _ = entry.insert(role.into());
648        self
649    }
650
651    /// Grants a permission tuple to a role.
652    ///
653    /// # Examples
654    ///
655    /// ```rust
656    /// use http_handle::enterprise::RbacAdapter;
657    /// let r = RbacAdapter::default().grant_permission("admin", "docs", "read");
658    /// assert!(!r.role_permissions.is_empty());
659    /// ```
660    ///
661    /// # Panics
662    ///
663    /// This function does not panic.
664    pub fn grant_permission(
665        mut self,
666        role: impl Into<String>,
667        resource: impl Into<String>,
668        action: impl Into<String>,
669    ) -> Self {
670        let entry =
671            self.role_permissions.entry(role.into()).or_default();
672        let _ = entry.insert((resource.into(), action.into()));
673        self
674    }
675}
676
677#[cfg(feature = "enterprise")]
678#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
679impl AuthorizationEngine for RbacAdapter {
680    fn evaluate(
681        &self,
682        context: &AuthorizationContext,
683    ) -> AuthorizationDecision {
684        let Some(roles) = self.subject_roles.get(&context.subject)
685        else {
686            return AuthorizationDecision::Deny(
687                "rbac: subject has no roles".to_string(),
688            );
689        };
690
691        let allowed = roles.iter().any(|role| {
692            self.role_permissions
693                .get(role)
694                .map(|perms| {
695                    perms.contains(&(
696                        context.resource.clone(),
697                        context.action.clone(),
698                    ))
699                })
700                .unwrap_or(false)
701        });
702
703        if allowed {
704            AuthorizationDecision::Allow
705        } else {
706            AuthorizationDecision::Deny(
707                "rbac: permission missing".to_string(),
708            )
709        }
710    }
711}
712
713/// ABAC rule for resource/action with required attributes.
714///
715/// # Examples
716///
717/// ```rust
718/// use http_handle::enterprise::AbacRule;
719/// let r = AbacRule::default();
720/// assert_eq!(r.resource, "");
721/// ```
722///
723/// # Panics
724///
725/// This type does not panic.
726#[cfg(feature = "enterprise")]
727#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
728#[derive(Clone, Debug, Default, PartialEq, Eq)]
729pub struct AbacRule {
730    /// Matched resource.
731    pub resource: String,
732    /// Matched action.
733    pub action: String,
734    /// Required attributes with allowed value sets.
735    pub required_attributes: HashMap<String, HashSet<String>>,
736}
737
738/// ABAC adapter backed by explicit rules.
739///
740/// # Examples
741///
742/// ```rust
743/// use http_handle::enterprise::AbacAdapter;
744/// let a = AbacAdapter::default();
745/// assert!(a.rules.is_empty());
746/// ```
747///
748/// # Panics
749///
750/// This type does not panic.
751#[cfg(feature = "enterprise")]
752#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
753#[derive(Clone, Debug, Default, PartialEq, Eq)]
754pub struct AbacAdapter {
755    /// Ordered rules evaluated with first-match semantics.
756    pub rules: Vec<AbacRule>,
757}
758
759#[cfg(feature = "enterprise")]
760#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
761impl AbacAdapter {
762    /// Adds a new ABAC rule.
763    ///
764    /// # Examples
765    ///
766    /// ```rust
767    /// use http_handle::enterprise::{AbacAdapter, AbacRule};
768    /// let a = AbacAdapter::default().with_rule(AbacRule::default());
769    /// assert_eq!(a.rules.len(), 1);
770    /// ```
771    ///
772    /// # Panics
773    ///
774    /// This function does not panic.
775    pub fn with_rule(mut self, rule: AbacRule) -> Self {
776        self.rules.push(rule);
777        self
778    }
779}
780
781#[cfg(feature = "enterprise")]
782#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
783impl AuthorizationEngine for AbacAdapter {
784    fn evaluate(
785        &self,
786        context: &AuthorizationContext,
787    ) -> AuthorizationDecision {
788        let Some(rule) = self.rules.iter().find(|rule| {
789            rule.resource == context.resource
790                && rule.action == context.action
791        }) else {
792            return AuthorizationDecision::Deny(
793                "abac: no matching rule".to_string(),
794            );
795        };
796
797        for (key, allowed_values) in &rule.required_attributes {
798            let Some(value) = context.attributes.get(key) else {
799                return AuthorizationDecision::Deny(format!(
800                    "abac: missing attribute '{key}'"
801                ));
802            };
803            if !allowed_values.contains(value) {
804                return AuthorizationDecision::Deny(format!(
805                    "abac: attribute '{key}' denied"
806                ));
807            }
808        }
809        AuthorizationDecision::Allow
810    }
811}
812
813/// Composite authorization hook that short-circuits on first deny.
814///
815/// # Examples
816///
817/// ```rust
818/// use http_handle::enterprise::AuthorizationHook;
819/// let h = AuthorizationHook::new();
820/// assert_eq!(format!("{h:?}").contains("AuthorizationHook"), true);
821/// ```
822///
823/// # Panics
824///
825/// This type does not panic.
826#[cfg(feature = "enterprise")]
827#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
828#[derive(Default)]
829pub struct AuthorizationHook {
830    engines: Vec<Box<dyn AuthorizationEngine>>,
831}
832
833#[cfg(feature = "enterprise")]
834#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
835impl std::fmt::Debug for AuthorizationHook {
836    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
837        f.debug_struct("AuthorizationHook")
838            .field("engines_len", &self.engines.len())
839            .finish()
840    }
841}
842
843#[cfg(feature = "enterprise")]
844#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
845impl AuthorizationHook {
846    /// Creates an empty authorization hook chain.
847    ///
848    /// # Examples
849    ///
850    /// ```rust
851    /// use http_handle::enterprise::AuthorizationHook;
852    /// let _h = AuthorizationHook::new();
853    /// assert_eq!(1, 1);
854    /// ```
855    ///
856    /// # Panics
857    ///
858    /// This function does not panic.
859    pub fn new() -> Self {
860        Self {
861            engines: Vec::new(),
862        }
863    }
864
865    /// Adds an authorization engine to the chain.
866    ///
867    /// # Examples
868    ///
869    /// ```rust
870    /// use http_handle::enterprise::{AuthorizationContext, AuthorizationDecision, AuthorizationEngine, AuthorizationHook};
871    /// struct Allow;
872    /// impl AuthorizationEngine for Allow {
873    ///     fn evaluate(&self, _context: &AuthorizationContext) -> AuthorizationDecision { AuthorizationDecision::Allow }
874    /// }
875    /// let _h = AuthorizationHook::new().with_engine(Allow);
876    /// assert_eq!(1, 1);
877    /// ```
878    ///
879    /// # Panics
880    ///
881    /// This function does not panic.
882    pub fn with_engine(
883        mut self,
884        engine: impl AuthorizationEngine + 'static,
885    ) -> Self {
886        self.engines.push(Box::new(engine));
887        self
888    }
889
890    /// Evaluates all engines in-order.
891    ///
892    /// # Examples
893    ///
894    /// ```rust
895    /// use http_handle::enterprise::{AuthorizationContext, AuthorizationDecision, AuthorizationEngine, AuthorizationHook};
896    /// struct Allow;
897    /// impl AuthorizationEngine for Allow {
898    ///     fn evaluate(&self, _context: &AuthorizationContext) -> AuthorizationDecision { AuthorizationDecision::Allow }
899    /// }
900    /// let h = AuthorizationHook::new().with_engine(Allow);
901    /// let d = h.evaluate(&AuthorizationContext::default());
902    /// assert!(matches!(d, AuthorizationDecision::Allow));
903    /// ```
904    ///
905    /// # Panics
906    ///
907    /// This function does not panic.
908    pub fn evaluate(
909        &self,
910        context: &AuthorizationContext,
911    ) -> AuthorizationDecision {
912        for engine in &self.engines {
913            let decision = engine.evaluate(context);
914            if decision != AuthorizationDecision::Allow {
915                return decision;
916            }
917        }
918        AuthorizationDecision::Allow
919    }
920
921    /// Evaluates authorization from an HTTP request.
922    ///
923    /// Use this helper to map request method and path into an authorization
924    /// context without repeating context construction in each handler.
925    ///
926    /// # Examples
927    ///
928    /// ```rust
929    /// use http_handle::enterprise::{AuthorizationDecision, AuthorizationHook, RbacAdapter};
930    /// use http_handle::request::Request;
931    /// use std::collections::HashMap;
932    ///
933    /// let auth = AuthorizationHook::new().with_engine(
934    ///     RbacAdapter::default()
935    ///         .grant_role("service-a", "reader")
936    ///         .grant_permission("reader", "/metrics", "GET"),
937    /// );
938    /// let request = Request {
939    ///     method: "GET".to_string(),
940    ///     path: "/metrics".to_string(),
941    ///     version: "HTTP/1.1".to_string(),
942    ///     headers: Vec::new(),
943    /// };
944    ///
945    /// let decision = auth.evaluate_http_request(
946    ///     &request,
947    ///     "service-a",
948    ///     HashMap::new(),
949    /// );
950    /// assert!(matches!(decision, AuthorizationDecision::Allow));
951    /// ```
952    ///
953    /// # Panics
954    ///
955    /// This function does not panic.
956    #[doc(alias = "authorize request")]
957    pub fn evaluate_http_request(
958        &self,
959        request: &Request,
960        subject: impl Into<String>,
961        attributes: HashMap<String, String>,
962    ) -> AuthorizationDecision {
963        let context = AuthorizationContext {
964            subject: subject.into(),
965            resource: request.path().to_string(),
966            action: request.method().to_string(),
967            attributes,
968        };
969        self.evaluate(&context)
970    }
971}
972
973/// Enforces authorization for an HTTP request.
974///
975/// # Examples
976///
977/// ```rust
978/// use http_handle::enterprise::{enforce_http_request_authorization, AuthorizationHook, RbacAdapter};
979/// use http_handle::request::Request;
980/// use std::collections::HashMap;
981///
982/// let auth = AuthorizationHook::new().with_engine(
983///     RbacAdapter::default()
984///         .grant_role("service-a", "reader")
985///         .grant_permission("reader", "/health", "GET"),
986/// );
987/// let request = Request {
988///     method: "GET".to_string(),
989///     path: "/health".to_string(),
990///     version: "HTTP/1.1".to_string(),
991///     headers: Vec::new(),
992/// };
993///
994/// let result = enforce_http_request_authorization(
995///     &auth,
996///     &request,
997///     "service-a",
998///     HashMap::new(),
999/// );
1000/// assert!(result.is_ok());
1001/// ```
1002///
1003/// # Errors
1004///
1005/// Returns `Err(ServerError::Forbidden)` when any authorization engine denies.
1006///
1007/// # Panics
1008///
1009/// This function does not panic.
1010#[cfg(feature = "enterprise")]
1011#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
1012#[doc(alias = "authz enforcement")]
1013pub fn enforce_http_request_authorization(
1014    hook: &AuthorizationHook,
1015    request: &Request,
1016    subject: impl Into<String>,
1017    attributes: HashMap<String, String>,
1018) -> Result<(), ServerError> {
1019    match hook.evaluate_http_request(request, subject, attributes) {
1020        AuthorizationDecision::Allow => Ok(()),
1021        AuthorizationDecision::Deny(reason) => {
1022            Err(ServerError::forbidden(reason))
1023        }
1024    }
1025}
1026
1027#[cfg(all(test, feature = "enterprise"))]
1028mod tests {
1029    use super::*;
1030    use tempfile::tempdir;
1031
1032    #[test]
1033    fn api_key_validation_works() {
1034        let policy = AuthPolicy {
1035            api_keys: vec!["k1".to_string(), "k2".to_string()],
1036            ..AuthPolicy::default()
1037        };
1038        assert!(validate_api_key(&policy, "k2"));
1039        assert!(!validate_api_key(&policy, "k3"));
1040    }
1041
1042    #[test]
1043    fn mtls_subject_allowlist_works() {
1044        let policy = AuthPolicy {
1045            mtls_subject_allowlist: vec!["CN=api-client".to_string()],
1046            ..AuthPolicy::default()
1047        };
1048        assert!(validate_mtls_subject(&policy, "CN=api-client"));
1049        assert!(!validate_mtls_subject(&policy, "CN=other"));
1050    }
1051
1052    #[test]
1053    fn production_baseline_is_strict() {
1054        let cfg = EnterpriseConfig::production_baseline();
1055        assert_eq!(cfg.profile, RuntimeProfile::Prod);
1056        assert!(cfg.tls.enabled);
1057        assert!(cfg.tls.mtls_enabled);
1058        assert!(cfg.telemetry.otlp_enabled);
1059        assert_eq!(cfg.telemetry.service_name, "http-handle");
1060    }
1061
1062    #[test]
1063    fn save_and_load_config_roundtrip() {
1064        let dir = tempdir().expect("tempdir");
1065        let path = dir.path().join("enterprise.toml");
1066
1067        let cfg = EnterpriseConfig::production_baseline();
1068        cfg.save_to_file(&path).expect("save");
1069        let loaded =
1070            EnterpriseConfig::load_from_file(&path).expect("load");
1071        assert_eq!(loaded, cfg);
1072    }
1073
1074    #[test]
1075    fn load_invalid_config_fails() {
1076        let dir = tempdir().expect("tempdir");
1077        let path = dir.path().join("bad.toml");
1078        std::fs::write(&path, "this-is-not-valid = [").expect("write");
1079        let err = EnterpriseConfig::load_from_file(&path)
1080            .expect_err("expected parse error");
1081        assert!(err.to_string().contains("invalid config"));
1082    }
1083
1084    #[test]
1085    fn reloader_watch_and_snapshot_work() {
1086        let dir = tempdir().expect("tempdir");
1087        let path = dir.path().join("enterprise.toml");
1088        EnterpriseConfig::default()
1089            .save_to_file(&path)
1090            .expect("write initial config");
1091
1092        let reloader =
1093            EnterpriseConfigReloader::watch(&path).expect("watch");
1094        let snap = reloader.snapshot();
1095        assert_eq!(snap.profile, RuntimeProfile::Dev);
1096    }
1097
1098    #[test]
1099    fn reloader_watch_missing_file_fails() {
1100        let dir = tempdir().expect("tempdir");
1101        let path = dir.path().join("missing.toml");
1102        assert!(EnterpriseConfigReloader::watch(path).is_err());
1103    }
1104
1105    #[test]
1106    fn audit_event_serializes_to_json() {
1107        let event = AccessAuditEvent {
1108            timestamp: "2026-02-20T00:00:00Z".to_string(),
1109            path: "/api/v1/resource".to_string(),
1110            method: "GET".to_string(),
1111            status_code: 200,
1112            trace_id: "trace-123".to_string(),
1113            subject: Some("service-a".to_string()),
1114        };
1115        let line = event.to_json_line().expect("json");
1116        assert!(line.contains("\"trace_id\":\"trace-123\""));
1117        assert!(line.contains("\"status_code\":200"));
1118    }
1119
1120    #[test]
1121    fn jwt_validation_enforces_segments() {
1122        let policy = AuthPolicy::default();
1123        let err = validate_jwt(&policy, "invalid-token")
1124            .expect_err("should reject malformed token");
1125        assert!(err.to_string().contains("3 segments"));
1126    }
1127
1128    #[test]
1129    fn jwt_validation_enforces_secret_env_when_configured() {
1130        let policy = AuthPolicy {
1131            jwt_secret_env: Some(
1132                "HTTP_HANDLE_TEST_SECRET_MISSING".into(),
1133            ),
1134            ..AuthPolicy::default()
1135        };
1136        let err = validate_jwt(&policy, "a.b.c")
1137            .expect_err("missing env should fail");
1138        assert!(err.to_string().contains("missing env var"));
1139    }
1140
1141    #[test]
1142    fn jwt_validation_accepts_three_segment_token_without_env() {
1143        let policy = AuthPolicy::default();
1144        validate_jwt(&policy, "a.b.c").expect("valid shape token");
1145    }
1146
1147    #[test]
1148    fn rbac_adapter_allows_assigned_permission() {
1149        let engine = RbacAdapter::default()
1150            .grant_role("alice", "admin")
1151            .grant_permission("admin", "settings", "write");
1152        let ctx = AuthorizationContext {
1153            subject: "alice".to_string(),
1154            resource: "settings".to_string(),
1155            action: "write".to_string(),
1156            attributes: HashMap::new(),
1157        };
1158        assert_eq!(engine.evaluate(&ctx), AuthorizationDecision::Allow);
1159    }
1160
1161    #[test]
1162    fn rbac_adapter_denies_missing_permission() {
1163        let engine = RbacAdapter::default()
1164            .grant_role("alice", "viewer")
1165            .grant_permission("viewer", "report", "read");
1166        let ctx = AuthorizationContext {
1167            subject: "alice".to_string(),
1168            resource: "report".to_string(),
1169            action: "write".to_string(),
1170            attributes: HashMap::new(),
1171        };
1172        assert!(matches!(
1173            engine.evaluate(&ctx),
1174            AuthorizationDecision::Deny(_)
1175        ));
1176    }
1177
1178    #[test]
1179    fn abac_adapter_allows_when_attributes_match() {
1180        let mut attrs = HashMap::new();
1181        let _ = attrs.insert(
1182            "tenant".to_string(),
1183            ["acme".to_string()].into_iter().collect(),
1184        );
1185        let engine = AbacAdapter::default().with_rule(AbacRule {
1186            resource: "invoice".to_string(),
1187            action: "read".to_string(),
1188            required_attributes: attrs,
1189        });
1190        let ctx = AuthorizationContext {
1191            subject: "bob".to_string(),
1192            resource: "invoice".to_string(),
1193            action: "read".to_string(),
1194            attributes: [("tenant".to_string(), "acme".to_string())]
1195                .into_iter()
1196                .collect(),
1197        };
1198        assert_eq!(engine.evaluate(&ctx), AuthorizationDecision::Allow);
1199    }
1200
1201    #[test]
1202    fn abac_adapter_denies_on_attribute_mismatch() {
1203        let mut attrs = HashMap::new();
1204        let _ = attrs.insert(
1205            "tenant".to_string(),
1206            ["acme".to_string()].into_iter().collect(),
1207        );
1208        let engine = AbacAdapter::default().with_rule(AbacRule {
1209            resource: "invoice".to_string(),
1210            action: "read".to_string(),
1211            required_attributes: attrs,
1212        });
1213        let ctx = AuthorizationContext {
1214            subject: "bob".to_string(),
1215            resource: "invoice".to_string(),
1216            action: "read".to_string(),
1217            attributes: [("tenant".to_string(), "other".to_string())]
1218                .into_iter()
1219                .collect(),
1220        };
1221        assert!(matches!(
1222            engine.evaluate(&ctx),
1223            AuthorizationDecision::Deny(_)
1224        ));
1225    }
1226
1227    #[test]
1228    fn authorization_hook_short_circuits_on_first_deny() {
1229        let rbac = RbacAdapter::default()
1230            .grant_role("svc", "reader")
1231            .grant_permission("reader", "doc", "read");
1232        let mut attrs = HashMap::new();
1233        let _ = attrs.insert(
1234            "env".to_string(),
1235            ["prod".to_string()].into_iter().collect(),
1236        );
1237        let abac = AbacAdapter::default().with_rule(AbacRule {
1238            resource: "doc".to_string(),
1239            action: "read".to_string(),
1240            required_attributes: attrs,
1241        });
1242        let hook = AuthorizationHook::new()
1243            .with_engine(rbac)
1244            .with_engine(abac);
1245        let denied_ctx = AuthorizationContext {
1246            subject: "svc".to_string(),
1247            resource: "doc".to_string(),
1248            action: "read".to_string(),
1249            attributes: [("env".to_string(), "dev".to_string())]
1250                .into_iter()
1251                .collect(),
1252        };
1253        assert!(matches!(
1254            hook.evaluate(&denied_ctx),
1255            AuthorizationDecision::Deny(_)
1256        ));
1257    }
1258
1259    #[test]
1260    fn mtls_validation_denies_when_allowlist_is_empty() {
1261        let policy = AuthPolicy::default();
1262        assert!(!validate_mtls_subject(&policy, "CN=any"));
1263    }
1264
1265    #[test]
1266    fn rbac_denies_subject_without_roles() {
1267        let engine = RbacAdapter::default();
1268        let ctx = AuthorizationContext {
1269            subject: "nobody".to_string(),
1270            resource: "settings".to_string(),
1271            action: "read".to_string(),
1272            attributes: HashMap::new(),
1273        };
1274        assert!(matches!(
1275            engine.evaluate(&ctx),
1276            AuthorizationDecision::Deny(_)
1277        ));
1278    }
1279
1280    #[test]
1281    fn abac_denies_without_matching_rule() {
1282        let engine = AbacAdapter::default().with_rule(AbacRule {
1283            resource: "invoice".to_string(),
1284            action: "read".to_string(),
1285            required_attributes: HashMap::new(),
1286        });
1287        let ctx = AuthorizationContext {
1288            subject: "bob".to_string(),
1289            resource: "other".to_string(),
1290            action: "read".to_string(),
1291            attributes: HashMap::new(),
1292        };
1293        assert!(matches!(
1294            engine.evaluate(&ctx),
1295            AuthorizationDecision::Deny(_)
1296        ));
1297    }
1298
1299    #[test]
1300    fn abac_denies_when_required_attribute_missing() {
1301        let mut attrs = HashMap::new();
1302        let _ = attrs.insert(
1303            "tenant".to_string(),
1304            ["acme".to_string()].into_iter().collect(),
1305        );
1306        let engine = AbacAdapter::default().with_rule(AbacRule {
1307            resource: "invoice".to_string(),
1308            action: "read".to_string(),
1309            required_attributes: attrs,
1310        });
1311        let ctx = AuthorizationContext {
1312            subject: "bob".to_string(),
1313            resource: "invoice".to_string(),
1314            action: "read".to_string(),
1315            attributes: HashMap::new(),
1316        };
1317        assert!(matches!(
1318            engine.evaluate(&ctx),
1319            AuthorizationDecision::Deny(_)
1320        ));
1321    }
1322
1323    #[test]
1324    fn authorization_hook_allows_when_all_engines_allow() {
1325        let rbac = RbacAdapter::default()
1326            .grant_role("svc", "reader")
1327            .grant_permission("reader", "doc", "read");
1328        let mut attrs = HashMap::new();
1329        let _ = attrs.insert(
1330            "env".to_string(),
1331            ["prod".to_string()].into_iter().collect(),
1332        );
1333        let abac = AbacAdapter::default().with_rule(AbacRule {
1334            resource: "doc".to_string(),
1335            action: "read".to_string(),
1336            required_attributes: attrs,
1337        });
1338        let hook = AuthorizationHook::new()
1339            .with_engine(rbac)
1340            .with_engine(abac);
1341        let ctx = AuthorizationContext {
1342            subject: "svc".to_string(),
1343            resource: "doc".to_string(),
1344            action: "read".to_string(),
1345            attributes: [("env".to_string(), "prod".to_string())]
1346                .into_iter()
1347                .collect(),
1348        };
1349        assert_eq!(hook.evaluate(&ctx), AuthorizationDecision::Allow);
1350    }
1351
1352    #[test]
1353    fn authorization_hook_debug_includes_engine_count() {
1354        let hook = AuthorizationHook::new()
1355            .with_engine(RbacAdapter::default());
1356        let dbg = format!("{hook:?}");
1357        assert!(dbg.contains("engines_len"));
1358    }
1359
1360    #[test]
1361    fn evaluate_http_request_maps_request_to_context() {
1362        let auth = AuthorizationHook::new().with_engine(
1363            RbacAdapter::default()
1364                .grant_role("svc", "reader")
1365                .grant_permission("reader", "/metrics", "GET"),
1366        );
1367        let request = Request {
1368            method: "GET".to_string(),
1369            path: "/metrics".to_string(),
1370            version: "HTTP/1.1".to_string(),
1371            headers: Vec::new(),
1372        };
1373
1374        let decision =
1375            auth.evaluate_http_request(&request, "svc", HashMap::new());
1376        assert_eq!(decision, AuthorizationDecision::Allow);
1377    }
1378
1379    #[test]
1380    fn enforce_http_request_authorization_maps_deny_to_forbidden() {
1381        let auth = AuthorizationHook::new().with_engine(
1382            RbacAdapter::default()
1383                .grant_role("svc", "reader")
1384                .grant_permission("reader", "/metrics", "GET"),
1385        );
1386        let request = Request {
1387            method: "GET".to_string(),
1388            path: "/admin".to_string(),
1389            version: "HTTP/1.1".to_string(),
1390            headers: Vec::new(),
1391        };
1392
1393        let err = enforce_http_request_authorization(
1394            &auth,
1395            &request,
1396            "svc",
1397            HashMap::new(),
1398        )
1399        .expect_err("authorization should deny");
1400        assert!(matches!(err, ServerError::Forbidden(_)));
1401    }
1402
1403    #[test]
1404    fn enforce_http_request_authorization_returns_ok_when_allowed() {
1405        let auth = AuthorizationHook::new().with_engine(
1406            RbacAdapter::default()
1407                .grant_role("svc", "reader")
1408                .grant_permission("reader", "/metrics", "GET"),
1409        );
1410        let request = Request {
1411            method: "GET".to_string(),
1412            path: "/metrics".to_string(),
1413            version: "HTTP/1.1".to_string(),
1414            headers: Vec::new(),
1415        };
1416
1417        enforce_http_request_authorization(
1418            &auth,
1419            &request,
1420            "svc",
1421            HashMap::new(),
1422        )
1423        .expect("should allow");
1424    }
1425
1426    #[test]
1427    fn error_context_helpers_wrap_source_message() {
1428        // serde_json::Error — cheap: deserialize a clearly invalid doc.
1429        let json_err =
1430            serde_json::from_str::<u32>("definitely-not-a-number")
1431                .expect_err("invalid number");
1432        let audit = audit_serialize_err(json_err);
1433        assert!(matches!(audit, ServerError::Custom(_)));
1434        assert!(audit.to_string().contains("audit serialize:"));
1435
1436        // toml::ser::Error — serializing a scalar at the root fails
1437        // because TOML requires a table at the root.
1438        let toml_err = toml::to_string_pretty(&42_u32)
1439            .expect_err("scalar root is not valid TOML");
1440        let cfg = serialize_config_err(toml_err);
1441        assert!(matches!(cfg, ServerError::Custom(_)));
1442        assert!(cfg.to_string().contains("serialize config:"));
1443
1444        // notify::Error has a public constructor from io::Error.
1445        let init_err = watcher_init_err(notify::Error::generic(
1446            "mock init failure",
1447        ));
1448        assert!(matches!(init_err, ServerError::Custom(_)));
1449        assert!(init_err.to_string().contains("watcher init failed:"));
1450
1451        let watch_err = watcher_watch_err(notify::Error::generic(
1452            "mock watch failure",
1453        ));
1454        assert!(matches!(watch_err, ServerError::Custom(_)));
1455        assert!(watch_err.to_string().contains("watch failed:"));
1456    }
1457
1458    #[test]
1459    fn reloader_applies_file_updates() {
1460        use std::time::{Duration, Instant};
1461        let dir = tempdir().expect("tempdir");
1462        let path = dir.path().join("enterprise.toml");
1463        EnterpriseConfig::default()
1464            .save_to_file(&path)
1465            .expect("initial write");
1466
1467        let reloader =
1468            EnterpriseConfigReloader::watch(&path).expect("watch");
1469        assert_eq!(reloader.snapshot().profile, RuntimeProfile::Dev);
1470
1471        // Give the watcher a moment to subscribe before we edit.
1472        std::thread::sleep(Duration::from_millis(100));
1473        EnterpriseConfig::production_baseline()
1474            .save_to_file(&path)
1475            .expect("update write");
1476
1477        // Wait for the async watcher callback to swap the atomic snapshot.
1478        let deadline = Instant::now() + Duration::from_secs(10);
1479        while Instant::now() < deadline {
1480            if reloader.snapshot().profile == RuntimeProfile::Prod {
1481                return;
1482            }
1483            std::thread::sleep(Duration::from_millis(100));
1484        }
1485        panic!(
1486            "reloader did not observe file update within 10s; final profile={:?}",
1487            reloader.snapshot().profile
1488        );
1489    }
1490}