1#[cfg(feature = "cedar")]
26use axum::{
27 body::Body,
28 extract::{MatchedPath, Request, State},
29 http::{HeaderMap, Method, StatusCode},
30 middleware::Next,
31 response::{IntoResponse, Response},
32};
33
34#[cfg(feature = "cedar")]
35use cedar_policy::{
36 Authorizer, Context, Decision, Entities, EntityUid, PolicySet, Request as CedarRequest,
37};
38
39#[cfg(feature = "cedar")]
40use chrono::{Datelike, Timelike};
41
42#[cfg(feature = "cedar")]
43use serde_json::json;
44
45#[cfg(feature = "cedar")]
46use std::sync::Arc;
47
48#[cfg(feature = "cedar")]
49use tokio::sync::RwLock;
50
51#[cfg(feature = "cedar")]
52use crate::{auth::user::User, config::{CedarConfig, FailureMode}};
53
54#[cfg(feature = "cedar")]
55use thiserror::Error;
56
57#[cfg(feature = "cedar")]
59#[derive(Debug, Error)]
60pub enum CedarError {
61 #[error("Cedar configuration error: {0}")]
63 Config(String),
64
65 #[error("Policy file error: {0}")]
67 PolicyFile(String),
68
69 #[error("Policy parsing error: {0}")]
71 PolicyParsing(String),
72
73 #[error("Authorization denied: {0}")]
75 Forbidden(String),
76
77 #[error("Unauthorized: {0}")]
79 Unauthorized(String),
80
81 #[error("Internal error: {0}")]
83 Internal(String),
84
85 #[error("IO error: {0}")]
87 Io(#[from] std::io::Error),
88
89 #[error("Task join error: {0}")]
91 JoinError(#[from] tokio::task::JoinError),
92}
93
94#[cfg(feature = "cedar")]
95impl IntoResponse for CedarError {
96 fn into_response(self) -> Response {
97 use axum::http::header;
98 use crate::template::FrameworkTemplates;
99 use std::sync::OnceLock;
100
101 static TEMPLATES: OnceLock<FrameworkTemplates> = OnceLock::new();
103
104 let (status, message, redirect_to_login) = match self {
105 Self::Forbidden(_) => (
106 StatusCode::FORBIDDEN,
107 "Access denied. You do not have permission to perform this action.",
108 false,
109 ),
110 Self::Unauthorized(_) => (
111 StatusCode::UNAUTHORIZED,
112 "Authentication required. Please sign in.",
113 true,
114 ),
115 _ => (
116 StatusCode::INTERNAL_SERVER_ERROR,
117 "An internal error occurred.",
118 false,
119 ),
120 };
121
122 tracing::error!(error = ?self, "Cedar authorization error");
123
124 if redirect_to_login {
126 return axum::response::Response::builder()
127 .status(status)
128 .header("HX-Redirect", "/auth/login")
129 .body(Body::empty())
130 .unwrap_or_else(|_| (status, message).into_response());
131 }
132
133 let html = TEMPLATES
135 .get_or_init(|| FrameworkTemplates::new().expect("Failed to initialize templates"))
136 .render(
137 &format!("errors/{}.html", status.as_u16()),
138 minijinja::context! {
139 message => message,
140 home_url => "/",
141 },
142 )
143 .unwrap_or_else(|e| {
144 tracing::error!(error = ?e, "Failed to render error template");
145 format!("<h1>{}</h1><p>{}</p>", status.as_u16(), message)
146 });
147
148 (status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
149 }
150}
151
152#[cfg(feature = "cedar")]
173pub struct CedarAuthzBuilder {
174 config: CedarConfig,
175 path_normalizer: Option<fn(&str) -> String>,
176}
177
178#[cfg(feature = "cedar")]
179impl CedarAuthzBuilder {
180 #[must_use]
182 pub fn new(config: CedarConfig) -> Self {
183 Self {
184 config,
185 path_normalizer: None,
186 }
187 }
188
189 #[must_use]
209 pub fn with_path_normalizer(mut self, normalizer: fn(&str) -> String) -> Self {
210 self.path_normalizer = Some(normalizer);
211 self
212 }
213
214 pub async fn build(self) -> Result<CedarAuthz, CedarError> {
226 let path = self.config.policy_path.clone();
228 let policies = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
229 .await??;
230
231 let policy_set: PolicySet = policies
232 .parse()
233 .map_err(|e| CedarError::PolicyParsing(format!("Failed to parse Cedar policies: {e}")))?;
234
235 Ok(CedarAuthz {
236 authorizer: Arc::new(Authorizer::new()),
237 policy_set: Arc::new(RwLock::new(policy_set)),
238 config: Arc::new(self.config),
239 path_normalizer: self.path_normalizer,
240 })
241 }
242}
243
244#[cfg(feature = "cedar")]
246#[derive(Clone)]
247pub struct CedarAuthz {
248 authorizer: Arc<Authorizer>,
250
251 policy_set: Arc<RwLock<PolicySet>>,
253
254 config: Arc<CedarConfig>,
256
257 path_normalizer: Option<fn(&str) -> String>,
259}
260
261#[cfg(feature = "cedar")]
262impl CedarAuthz {
263 #[must_use]
276 pub fn builder(config: CedarConfig) -> CedarAuthzBuilder {
277 CedarAuthzBuilder::new(config)
278 }
279
280 pub async fn from_config(config: CedarConfig) -> Result<Self, CedarError> {
295 Self::builder(config).build().await
296 }
297
298 #[allow(clippy::cognitive_complexity)] pub async fn middleware(
317 State(authz): State<Self>,
318 request: Request<Body>,
319 next: Next,
320 ) -> Result<Response, CedarError> {
321 if !authz.config.enabled {
323 return Ok(next.run(request).await);
324 }
325
326 let path = request.uri().path();
328 if path == "/health" || path == "/ready" {
329 return Ok(next.run(request).await);
330 }
331
332 let user = request.extensions().get::<User>().ok_or_else(|| {
334 CedarError::Unauthorized(
335 "Missing user session. Ensure session middleware runs before Cedar middleware."
336 .to_string(),
337 )
338 })?;
339
340 let method = request.method().clone();
342
343 let principal = build_principal(user)?;
345 let action = build_action_http(&method, &request, authz.path_normalizer)?;
346 let context = build_context_http(request.headers(), user)?;
347
348 let resource = build_resource()?;
350
351 let cedar_request = CedarRequest::new(
352 principal.clone(),
353 action.clone(),
354 resource.clone(),
355 context,
356 None, )
358 .map_err(|e| CedarError::Internal(format!("Failed to build Cedar request: {e}")))?;
359
360 let entities = build_entities(user)?;
362 let response = {
363 let policy_set = authz.policy_set.read().await;
364 authz
365 .authorizer
366 .is_authorized(&cedar_request, &policy_set, &entities)
367 };
368
369 if tracing::enabled!(tracing::Level::DEBUG) {
371 tracing::debug!(
372 principal = ?principal,
373 action = ?action,
374 resource = ?resource,
375 decision = ?response.decision(),
376 user_id = user.id,
377 user_email = %user.email,
378 user_roles = ?user.roles,
379 user_permissions = ?user.permissions,
380 diagnostics = ?response.diagnostics(),
381 "Cedar policy evaluation completed"
382 );
383 }
384
385 match response.decision() {
387 Decision::Allow => {
388 tracing::trace!(
390 principal = ?principal,
391 action = ?action,
392 user_id = user.id,
393 "Cedar policy allowed request"
394 );
395
396 Ok(next.run(request).await)
398 }
399 Decision::Deny => {
400 tracing::warn!(
401 principal = ?principal,
402 action = ?action,
403 user_id = user.id,
404 user_email = %user.email,
405 user_roles = ?user.roles,
406 diagnostics = ?response.diagnostics(),
407 "Cedar policy denied request"
408 );
409
410 if authz.config.failure_mode == FailureMode::Open {
411 tracing::warn!("Cedar policy denied but failure_mode=Open, allowing request");
412 Ok(next.run(request).await)
413 } else {
414 Err(CedarError::Forbidden(
415 "Access denied by policy".to_string(),
416 ))
417 }
418 }
419 }
420 }
421
422 pub async fn reload_policies(&self) -> Result<(), CedarError> {
432 let path = self.config.policy_path.clone();
433 let policies = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path)).await??;
434
435 let new_policy_set: PolicySet = policies
436 .parse()
437 .map_err(|e| CedarError::PolicyParsing(format!("Failed to parse policies: {e}")))?;
438
439 {
440 let mut policy_set = self.policy_set.write().await;
441 *policy_set = new_policy_set;
442 }
443
444 tracing::info!(
445 "Cedar policies reloaded from {}",
446 self.config.policy_path.display()
447 );
448 Ok(())
449 }
450
451 #[allow(clippy::cognitive_complexity)] pub async fn can_perform(
481 &self,
482 user: &User,
483 action: &str,
484 #[allow(unused_variables)] resource_id: Option<i64>,
485 ) -> bool {
486 if !self.config.enabled {
488 return true;
489 }
490
491 let principal = match build_principal(user) {
493 Ok(p) => p,
494 Err(e) => {
495 tracing::error!(error = ?e, "Failed to build principal for can_perform");
496 return false;
497 }
498 };
499
500 let action_entity = match parse_action_string(action) {
501 Ok(a) => a,
502 Err(e) => {
503 tracing::error!(error = ?e, action = %action, "Failed to parse action for can_perform");
504 return false;
505 }
506 };
507
508 let resource = match build_resource() {
510 Ok(r) => r,
511 Err(e) => {
512 tracing::error!(error = ?e, "Failed to build resource for can_perform");
513 return false;
514 }
515 };
516
517 let context = match build_context_for_user(user) {
519 Ok(c) => c,
520 Err(e) => {
521 tracing::error!(error = ?e, "Failed to build context for can_perform");
522 return false;
523 }
524 };
525
526 let cedar_request = match CedarRequest::new(principal.clone(), action_entity.clone(), resource, context, None) {
528 Ok(r) => r,
529 Err(e) => {
530 tracing::error!(error = ?e, "Failed to create Cedar request for can_perform");
531 return false;
532 }
533 };
534
535 let entities = match build_entities(user) {
537 Ok(e) => e,
538 Err(e) => {
539 tracing::error!(error = ?e, "Failed to build entities for can_perform");
540 return false;
541 }
542 };
543
544 let response = {
546 let policy_set = self.policy_set.read().await;
547 self.authorizer
548 .is_authorized(&cedar_request, &policy_set, &entities)
549 };
550
551 matches!(response.decision(), Decision::Allow)
553 }
554
555 pub async fn can_update(&self, user: &User, resource_path: &str) -> bool {
567 self.can_perform(user, &format!("PUT {resource_path}"), None)
568 .await
569 }
570
571 pub async fn can_delete(&self, user: &User, resource_path: &str) -> bool {
583 self.can_perform(user, &format!("DELETE {resource_path}"), None)
584 .await
585 }
586
587 pub async fn can_create(&self, user: &User, resource_path: &str) -> bool {
599 self.can_perform(user, &format!("POST {resource_path}"), None)
600 .await
601 }
602
603 pub async fn can_read(&self, user: &User, resource_path: &str) -> bool {
615 self.can_perform(user, &format!("GET {resource_path}"), None)
616 .await
617 }
618
619 #[must_use]
624 pub fn config(&self) -> &CedarConfig {
625 &self.config
626 }
627}
628
629#[cfg(feature = "cedar")]
638fn build_resource() -> Result<EntityUid, CedarError> {
639 r#"Resource::"default""#
640 .parse()
641 .map_err(|e| CedarError::Internal(format!("Failed to parse resource: {e}")))
642}
643
644#[cfg(feature = "cedar")]
649fn build_principal(user: &User) -> Result<EntityUid, CedarError> {
650 let principal_str = format!(r#"User::"{}""#, user.id);
651
652 let principal: EntityUid = principal_str
653 .parse()
654 .map_err(|e| CedarError::Internal(format!("Invalid principal: {e}")))?;
655
656 Ok(principal)
657}
658
659#[cfg(feature = "cedar")]
664fn build_action_http(
665 method: &Method,
666 request: &Request<Body>,
667 path_normalizer: Option<fn(&str) -> String>,
668) -> Result<EntityUid, CedarError> {
669 let normalized_path = request
671 .extensions()
672 .get::<MatchedPath>()
673 .map_or_else(
674 || {
675 path_normalizer.map_or_else(
677 || normalize_path_generic(request.uri().path()),
678 |normalizer| normalizer(request.uri().path()),
679 )
680 },
681 |matched| matched.as_str().to_string(),
682 );
683
684 let action_str = format!(r#"Action::"{method} {normalized_path}""#);
685
686 let action: EntityUid = action_str
687 .parse()
688 .map_err(|e| CedarError::Internal(format!("Invalid action: {e}")))?;
689
690 tracing::debug!(
692 method = %method,
693 path = %request.uri().path(),
694 normalized = %normalized_path,
695 action = %action,
696 "Built Cedar action"
697 );
698
699 Ok(action)
700}
701
702#[cfg(feature = "cedar")]
709fn normalize_path_generic(path: &str) -> String {
710 let uuid_pattern =
712 regex::Regex::new(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
713 .expect("Invalid regex");
714 let path = uuid_pattern.replace_all(path, "{id}");
715
716 let numeric_pattern = regex::Regex::new(r"/(\d+)(/|$)").expect("Invalid regex");
718 let path = numeric_pattern.replace_all(&path, "/{id}${2}");
719
720 path.to_string()
721}
722
723#[cfg(feature = "cedar")]
725fn build_context_http(headers: &HeaderMap, user: &User) -> Result<Context, CedarError> {
726 let mut context_map = serde_json::Map::new();
727
728 context_map.insert("roles".to_string(), json!(user.roles));
730
731 context_map.insert("permissions".to_string(), json!(user.permissions));
733
734 context_map.insert("email".to_string(), json!(user.email.as_str()));
736
737 context_map.insert("user_id".to_string(), json!(user.id));
739
740 context_map.insert("verified".to_string(), json!(user.email_verified));
742
743 let now = chrono::Utc::now();
745 context_map.insert(
746 "timestamp".to_string(),
747 json!({
748 "unix": now.timestamp(),
749 "hour": now.hour(),
750 "dayOfWeek": now.weekday().to_string(),
751 }),
752 );
753
754 if let Some(ip) = extract_client_ip(headers) {
756 context_map.insert("ip".to_string(), json!(ip));
757 }
758
759 if let Some(request_id) = headers
761 .get("x-request-id")
762 .and_then(|v| v.to_str().ok())
763 {
764 context_map.insert("requestId".to_string(), json!(request_id));
765 }
766
767 if let Some(user_agent) = headers.get("user-agent").and_then(|v| v.to_str().ok()) {
769 context_map.insert("userAgent".to_string(), json!(user_agent));
770 }
771
772 Context::from_json_value(serde_json::Value::Object(context_map), None)
773 .map_err(|e| CedarError::Internal(format!("Failed to build context: {e}")))
774}
775
776#[cfg(feature = "cedar")]
778fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
779 if let Some(xff) = headers.get("x-forwarded-for") {
781 if let Ok(xff_str) = xff.to_str() {
782 return xff_str.split(',').next().map(|s| s.trim().to_string());
784 }
785 }
786
787 if let Some(xri) = headers.get("x-real-ip") {
789 if let Ok(xri_str) = xri.to_str() {
790 return Some(xri_str.to_string());
791 }
792 }
793
794 None
795}
796
797#[cfg(feature = "cedar")]
801fn build_entities(user: &User) -> Result<Entities, CedarError> {
802 use serde_json::Value;
803
804 let entity = json!({
806 "uid": {
807 "type": "User",
808 "id": user.id.to_string()
809 },
810 "attrs": {
811 "email": user.email.as_str(),
812 "roles": user.roles.clone(),
813 "permissions": user.permissions.clone(),
814 "id": user.id,
815 "verified": user.email_verified,
816 },
817 "parents": []
818 });
819
820 Entities::from_json_value(Value::Array(vec![entity]), None)
821 .map_err(|e| CedarError::Internal(format!("Failed to build entities: {e}")))
822}
823
824#[cfg(feature = "cedar")]
828fn parse_action_string(action: &str) -> Result<EntityUid, CedarError> {
829 let action_str = format!(r#"Action::"{action}""#);
830 action_str
831 .parse()
832 .map_err(|e| CedarError::Internal(format!("Failed to parse action '{action}': {e}")))
833}
834
835#[cfg(feature = "cedar")]
840fn build_context_for_user(user: &User) -> Result<Context, CedarError> {
841 let mut context_map = serde_json::Map::new();
842
843 context_map.insert("roles".to_string(), json!(user.roles));
845
846 context_map.insert("permissions".to_string(), json!(user.permissions));
848
849 context_map.insert("email".to_string(), json!(user.email.as_str()));
851
852 context_map.insert("user_id".to_string(), json!(user.id));
854
855 context_map.insert("verified".to_string(), json!(user.email_verified));
857
858 let now = chrono::Utc::now();
860 context_map.insert(
861 "timestamp".to_string(),
862 json!({
863 "unix": now.timestamp(),
864 "hour": now.hour(),
865 "dayOfWeek": now.weekday().to_string(),
866 }),
867 );
868
869 Context::from_json_value(serde_json::Value::Object(context_map), None)
870 .map_err(|e| CedarError::Internal(format!("Failed to build context for user: {e}")))
871}
872
873#[cfg(test)]
874#[cfg(feature = "cedar")]
875mod tests {
876 use super::*;
877
878 #[test]
879 fn test_normalize_path_generic() {
880 assert_eq!(
881 normalize_path_generic("/api/v1/posts/123"),
882 "/api/v1/posts/{id}"
883 );
884 assert_eq!(
885 normalize_path_generic("/api/v1/posts/550e8400-e29b-41d4-a716-446655440000"),
886 "/api/v1/posts/{id}"
887 );
888 assert_eq!(normalize_path_generic("/api/v1/posts"), "/api/v1/posts");
889 }
890
891 #[test]
892 fn test_normalize_path_multiple_ids() {
893 assert_eq!(
894 normalize_path_generic("/api/posts/123/comments/456"),
895 "/api/posts/{id}/comments/{id}"
896 );
897 }
898
899 #[test]
900 fn test_parse_action_string() {
901 let result = parse_action_string("GET /posts");
902 assert!(result.is_ok());
903
904 let result = parse_action_string("PUT /posts/{id}");
905 assert!(result.is_ok());
906
907 let result = parse_action_string("DELETE /posts/{id}");
908 assert!(result.is_ok());
909 }
910
911 #[test]
912 fn test_build_principal() {
913 use crate::auth::user::EmailAddress;
914
915 let user = User {
916 id: 123,
917 email: EmailAddress::parse("test@example.com").unwrap(),
918 password_hash: "hash".to_string(),
919 roles: vec!["user".to_string()],
920 permissions: vec![],
921 email_verified: true,
922 created_at: chrono::Utc::now(),
923 updated_at: chrono::Utc::now(),
924 };
925
926 let principal = build_principal(&user);
927 assert!(principal.is_ok());
928
929 let principal = principal.unwrap();
930 assert_eq!(principal.to_string(), r#"User::"123""#);
931 }
932
933 #[test]
934 fn test_build_context_for_user() {
935 use crate::auth::user::EmailAddress;
936
937 let user = User {
938 id: 123,
939 email: EmailAddress::parse("test@example.com").unwrap(),
940 password_hash: "hash".to_string(),
941 roles: vec!["user".to_string(), "moderator".to_string()],
942 permissions: vec!["read:posts".to_string()],
943 email_verified: true,
944 created_at: chrono::Utc::now(),
945 updated_at: chrono::Utc::now(),
946 };
947
948 let context = build_context_for_user(&user);
949 assert!(context.is_ok());
950 }
951
952 #[test]
953 fn test_build_entities() {
954 use crate::auth::user::EmailAddress;
955
956 let user = User {
957 id: 123,
958 email: EmailAddress::parse("test@example.com").unwrap(),
959 password_hash: "hash".to_string(),
960 roles: vec!["user".to_string(), "admin".to_string()],
961 permissions: vec!["write:posts".to_string()],
962 email_verified: true,
963 created_at: chrono::Utc::now(),
964 updated_at: chrono::Utc::now(),
965 };
966
967 let entities = build_entities(&user);
968 assert!(entities.is_ok());
969 }
970
971 #[test]
972 fn test_build_resource() {
973 let resource = build_resource();
974 assert!(resource.is_ok());
975 assert_eq!(resource.unwrap().to_string(), r#"Resource::"default""#);
976 }
977
978 }