1#[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#[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 #[default]
72 Dev,
73 Staging,
75 Prod,
77}
78
79#[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 pub enabled: bool,
100 pub cert_chain_path: Option<PathBuf>,
102 pub private_key_path: Option<PathBuf>,
104 pub mtls_enabled: bool,
106 pub client_ca_bundle_path: Option<PathBuf>,
108}
109
110#[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 pub api_keys: Vec<String>,
131 pub jwt_issuer: Option<String>,
133 pub jwt_audience: Option<String>,
135 pub jwt_secret_env: Option<String>,
137 pub mtls_subject_allowlist: Vec<String>,
139}
140
141#[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 pub otlp_enabled: bool,
162 pub otlp_endpoint: Option<String>,
164 pub service_name: String,
166}
167
168#[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 pub profile: RuntimeProfile,
189 pub tls: TlsPolicy,
191 pub auth: AuthPolicy,
193 pub telemetry: TelemetryPolicy,
195}
196
197#[cfg(feature = "enterprise")]
198#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
199impl EnterpriseConfig {
200 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 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 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#[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 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(¤t);
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 pub fn snapshot(&self) -> Arc<EnterpriseConfig> {
374 self.current.load_full()
375 }
376}
377
378#[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 pub timestamp: String,
399 pub path: String,
401 pub method: String,
403 pub status_code: u16,
405 pub trace_id: String,
407 pub subject: Option<String>,
409}
410
411#[cfg(feature = "enterprise")]
412#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
413impl AccessAuditEvent {
414 pub fn to_json_line(&self) -> Result<String, ServerError> {
433 serde_json::to_string(self).map_err(audit_serialize_err)
434 }
435}
436
437#[cfg(feature = "enterprise")]
439#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
440pub 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#[cfg(feature = "enterprise")]
460#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
461pub fn validate_jwt(
479 policy: &AuthPolicy,
480 token: &str,
481) -> Result<(), ServerError> {
482 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#[cfg(feature = "enterprise")]
503#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
504pub 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#[cfg(feature = "enterprise")]
543#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
544#[derive(Clone, Debug, Default, PartialEq, Eq)]
545pub struct AuthorizationContext {
546 pub subject: String,
548 pub resource: String,
550 pub action: String,
552 pub attributes: HashMap<String, String>,
554}
555
556#[cfg(feature = "enterprise")]
569#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
570#[derive(Clone, Debug, PartialEq, Eq)]
571pub enum AuthorizationDecision {
572 Allow,
574 Deny(String),
576}
577
578#[cfg(feature = "enterprise")]
580#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
581pub trait AuthorizationEngine: Send + Sync {
594 fn evaluate(
596 &self,
597 context: &AuthorizationContext,
598 ) -> AuthorizationDecision;
599}
600
601#[cfg(feature = "enterprise")]
615#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
616#[derive(Clone, Debug, Default, PartialEq, Eq)]
617pub struct RbacAdapter {
618 pub subject_roles: HashMap<String, HashSet<String>>,
620 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 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 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#[cfg(feature = "enterprise")]
727#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
728#[derive(Clone, Debug, Default, PartialEq, Eq)]
729pub struct AbacRule {
730 pub resource: String,
732 pub action: String,
734 pub required_attributes: HashMap<String, HashSet<String>>,
736}
737
738#[cfg(feature = "enterprise")]
752#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
753#[derive(Clone, Debug, Default, PartialEq, Eq)]
754pub struct AbacAdapter {
755 pub rules: Vec<AbacRule>,
757}
758
759#[cfg(feature = "enterprise")]
760#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
761impl AbacAdapter {
762 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#[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 pub fn new() -> Self {
860 Self {
861 engines: Vec::new(),
862 }
863 }
864
865 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 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 #[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#[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 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 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 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 std::thread::sleep(Duration::from_millis(100));
1473 EnterpriseConfig::production_baseline()
1474 .save_to_file(&path)
1475 .expect("update write");
1476
1477 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}