Skip to main content

http_handle/
enterprise.rs

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