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")]
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 #[default]
52 Dev,
53 Staging,
55 Prod,
57}
58
59#[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 pub enabled: bool,
80 pub cert_chain_path: Option<PathBuf>,
82 pub private_key_path: Option<PathBuf>,
84 pub mtls_enabled: bool,
86 pub client_ca_bundle_path: Option<PathBuf>,
88}
89
90#[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 pub api_keys: Vec<String>,
111 pub jwt_issuer: Option<String>,
113 pub jwt_audience: Option<String>,
115 pub jwt_secret_env: Option<String>,
117 pub mtls_subject_allowlist: Vec<String>,
119}
120
121#[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 pub otlp_enabled: bool,
142 pub otlp_endpoint: Option<String>,
144 pub service_name: String,
146}
147
148#[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 pub profile: RuntimeProfile,
169 pub tls: TlsPolicy,
171 pub auth: AuthPolicy,
173 pub telemetry: TelemetryPolicy,
175}
176
177#[cfg(feature = "enterprise")]
178#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
179impl EnterpriseConfig {
180 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 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 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#[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 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(¤t);
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 pub fn snapshot(&self) -> Arc<EnterpriseConfig> {
357 self.current.load_full()
358 }
359}
360
361#[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 pub timestamp: String,
382 pub path: String,
384 pub method: String,
386 pub status_code: u16,
388 pub trace_id: String,
390 pub subject: Option<String>,
392}
393
394#[cfg(feature = "enterprise")]
395#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
396impl AccessAuditEvent {
397 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#[cfg(feature = "enterprise")]
424#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
425pub 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#[cfg(feature = "enterprise")]
445#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
446pub fn validate_jwt(
464 policy: &AuthPolicy,
465 token: &str,
466) -> Result<(), ServerError> {
467 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#[cfg(feature = "enterprise")]
488#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
489pub 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#[cfg(feature = "enterprise")]
528#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
529#[derive(Clone, Debug, Default, PartialEq, Eq)]
530pub struct AuthorizationContext {
531 pub subject: String,
533 pub resource: String,
535 pub action: String,
537 pub attributes: HashMap<String, String>,
539}
540
541#[cfg(feature = "enterprise")]
554#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
555#[derive(Clone, Debug, PartialEq, Eq)]
556pub enum AuthorizationDecision {
557 Allow,
559 Deny(String),
561}
562
563#[cfg(feature = "enterprise")]
565#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
566pub trait AuthorizationEngine: Send + Sync {
579 fn evaluate(
581 &self,
582 context: &AuthorizationContext,
583 ) -> AuthorizationDecision;
584}
585
586#[cfg(feature = "enterprise")]
600#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
601#[derive(Clone, Debug, Default, PartialEq, Eq)]
602pub struct RbacAdapter {
603 pub subject_roles: HashMap<String, HashSet<String>>,
605 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 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 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#[cfg(feature = "enterprise")]
712#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
713#[derive(Clone, Debug, Default, PartialEq, Eq)]
714pub struct AbacRule {
715 pub resource: String,
717 pub action: String,
719 pub required_attributes: HashMap<String, HashSet<String>>,
721}
722
723#[cfg(feature = "enterprise")]
737#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
738#[derive(Clone, Debug, Default, PartialEq, Eq)]
739pub struct AbacAdapter {
740 pub rules: Vec<AbacRule>,
742}
743
744#[cfg(feature = "enterprise")]
745#[cfg_attr(docsrs, doc(cfg(feature = "enterprise")))]
746impl AbacAdapter {
747 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#[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 pub fn new() -> Self {
845 Self {
846 engines: Vec::new(),
847 }
848 }
849
850 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 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 #[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#[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}