#[cfg(feature = "cedar")]
use axum::{
body::Body,
extract::{MatchedPath, Request, State},
http::{HeaderMap, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
#[cfg(feature = "cedar")]
use cedar_policy::{
Authorizer, Context, Decision, Entities, EntityUid, PolicySet, Request as CedarRequest,
};
#[cfg(feature = "cedar")]
use chrono::{Datelike, Timelike};
#[cfg(feature = "cedar")]
use serde_json::json;
#[cfg(feature = "cedar")]
use std::sync::Arc;
#[cfg(feature = "cedar")]
use tokio::sync::RwLock;
#[cfg(feature = "cedar")]
use crate::{auth::user::User, config::{CedarConfig, FailureMode}};
#[cfg(feature = "cedar")]
use thiserror::Error;
#[cfg(feature = "cedar")]
#[derive(Debug, Error)]
pub enum CedarError {
#[error("Cedar configuration error: {0}")]
Config(String),
#[error("Policy file error: {0}")]
PolicyFile(String),
#[error("Policy parsing error: {0}")]
PolicyParsing(String),
#[error("Authorization denied: {0}")]
Forbidden(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
#[error("Internal error: {0}")]
Internal(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Task join error: {0}")]
JoinError(#[from] tokio::task::JoinError),
}
#[cfg(feature = "cedar")]
impl IntoResponse for CedarError {
fn into_response(self) -> Response {
use axum::http::header;
use crate::template::FrameworkTemplates;
use std::sync::OnceLock;
static TEMPLATES: OnceLock<FrameworkTemplates> = OnceLock::new();
let (status, message, redirect_to_login) = match self {
Self::Forbidden(_) => (
StatusCode::FORBIDDEN,
"Access denied. You do not have permission to perform this action.",
false,
),
Self::Unauthorized(_) => (
StatusCode::UNAUTHORIZED,
"Authentication required. Please sign in.",
true,
),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"An internal error occurred.",
false,
),
};
tracing::error!(error = ?self, "Cedar authorization error");
if redirect_to_login {
return axum::response::Response::builder()
.status(status)
.header("HX-Redirect", "/auth/login")
.body(Body::empty())
.unwrap_or_else(|_| (status, message).into_response());
}
let html = TEMPLATES
.get_or_init(|| FrameworkTemplates::new().expect("Failed to initialize templates"))
.render(
&format!("errors/{}.html", status.as_u16()),
minijinja::context! {
message => message,
home_url => "/",
},
)
.unwrap_or_else(|e| {
tracing::error!(error = ?e, "Failed to render error template");
format!("<h1>{}</h1><p>{}</p>", status.as_u16(), message)
});
(status, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}
}
#[cfg(feature = "cedar")]
pub struct CedarAuthzBuilder {
config: CedarConfig,
path_normalizer: Option<fn(&str) -> String>,
}
#[cfg(feature = "cedar")]
impl CedarAuthzBuilder {
#[must_use]
pub fn new(config: CedarConfig) -> Self {
Self {
config,
path_normalizer: None,
}
}
#[must_use]
pub fn with_path_normalizer(mut self, normalizer: fn(&str) -> String) -> Self {
self.path_normalizer = Some(normalizer);
self
}
pub async fn build(self) -> Result<CedarAuthz, CedarError> {
let path = self.config.policy_path.clone();
let policies = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
.await??;
let policy_set: PolicySet = policies
.parse()
.map_err(|e| CedarError::PolicyParsing(format!("Failed to parse Cedar policies: {e}")))?;
Ok(CedarAuthz {
authorizer: Arc::new(Authorizer::new()),
policy_set: Arc::new(RwLock::new(policy_set)),
config: Arc::new(self.config),
path_normalizer: self.path_normalizer,
})
}
}
#[cfg(feature = "cedar")]
#[derive(Clone)]
pub struct CedarAuthz {
authorizer: Arc<Authorizer>,
policy_set: Arc<RwLock<PolicySet>>,
config: Arc<CedarConfig>,
path_normalizer: Option<fn(&str) -> String>,
}
#[cfg(feature = "cedar")]
impl CedarAuthz {
#[must_use]
pub fn builder(config: CedarConfig) -> CedarAuthzBuilder {
CedarAuthzBuilder::new(config)
}
pub async fn from_config(config: CedarConfig) -> Result<Self, CedarError> {
Self::builder(config).build().await
}
#[allow(clippy::cognitive_complexity)] pub async fn middleware(
State(authz): State<Self>,
request: Request<Body>,
next: Next,
) -> Result<Response, CedarError> {
if !authz.config.enabled {
return Ok(next.run(request).await);
}
let path = request.uri().path();
if path == "/health" || path == "/ready" {
return Ok(next.run(request).await);
}
let user = request.extensions().get::<User>().ok_or_else(|| {
CedarError::Unauthorized(
"Missing user session. Ensure session middleware runs before Cedar middleware."
.to_string(),
)
})?;
let method = request.method().clone();
let principal = build_principal(user)?;
let action = build_action_http(&method, &request, authz.path_normalizer)?;
let context = build_context_http(request.headers(), user)?;
let resource = build_resource()?;
let cedar_request = CedarRequest::new(
principal.clone(),
action.clone(),
resource.clone(),
context,
None, )
.map_err(|e| CedarError::Internal(format!("Failed to build Cedar request: {e}")))?;
let entities = build_entities(user)?;
let response = {
let policy_set = authz.policy_set.read().await;
authz
.authorizer
.is_authorized(&cedar_request, &policy_set, &entities)
};
if tracing::enabled!(tracing::Level::DEBUG) {
tracing::debug!(
principal = ?principal,
action = ?action,
resource = ?resource,
decision = ?response.decision(),
user_id = user.id,
user_email = %user.email,
user_roles = ?user.roles,
user_permissions = ?user.permissions,
diagnostics = ?response.diagnostics(),
"Cedar policy evaluation completed"
);
}
match response.decision() {
Decision::Allow => {
tracing::trace!(
principal = ?principal,
action = ?action,
user_id = user.id,
"Cedar policy allowed request"
);
Ok(next.run(request).await)
}
Decision::Deny => {
tracing::warn!(
principal = ?principal,
action = ?action,
user_id = user.id,
user_email = %user.email,
user_roles = ?user.roles,
diagnostics = ?response.diagnostics(),
"Cedar policy denied request"
);
if authz.config.failure_mode == FailureMode::Open {
tracing::warn!("Cedar policy denied but failure_mode=Open, allowing request");
Ok(next.run(request).await)
} else {
Err(CedarError::Forbidden(
"Access denied by policy".to_string(),
))
}
}
}
}
pub async fn reload_policies(&self) -> Result<(), CedarError> {
let path = self.config.policy_path.clone();
let policies = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path)).await??;
let new_policy_set: PolicySet = policies
.parse()
.map_err(|e| CedarError::PolicyParsing(format!("Failed to parse policies: {e}")))?;
{
let mut policy_set = self.policy_set.write().await;
*policy_set = new_policy_set;
}
tracing::info!(
"Cedar policies reloaded from {}",
self.config.policy_path.display()
);
Ok(())
}
#[allow(clippy::cognitive_complexity)] pub async fn can_perform(
&self,
user: &User,
action: &str,
#[allow(unused_variables)] resource_id: Option<i64>,
) -> bool {
if !self.config.enabled {
return true;
}
let principal = match build_principal(user) {
Ok(p) => p,
Err(e) => {
tracing::error!(error = ?e, "Failed to build principal for can_perform");
return false;
}
};
let action_entity = match parse_action_string(action) {
Ok(a) => a,
Err(e) => {
tracing::error!(error = ?e, action = %action, "Failed to parse action for can_perform");
return false;
}
};
let resource = match build_resource() {
Ok(r) => r,
Err(e) => {
tracing::error!(error = ?e, "Failed to build resource for can_perform");
return false;
}
};
let context = match build_context_for_user(user) {
Ok(c) => c,
Err(e) => {
tracing::error!(error = ?e, "Failed to build context for can_perform");
return false;
}
};
let cedar_request = match CedarRequest::new(principal.clone(), action_entity.clone(), resource, context, None) {
Ok(r) => r,
Err(e) => {
tracing::error!(error = ?e, "Failed to create Cedar request for can_perform");
return false;
}
};
let entities = match build_entities(user) {
Ok(e) => e,
Err(e) => {
tracing::error!(error = ?e, "Failed to build entities for can_perform");
return false;
}
};
let response = {
let policy_set = self.policy_set.read().await;
self.authorizer
.is_authorized(&cedar_request, &policy_set, &entities)
};
matches!(response.decision(), Decision::Allow)
}
pub async fn can_update(&self, user: &User, resource_path: &str) -> bool {
self.can_perform(user, &format!("PUT {resource_path}"), None)
.await
}
pub async fn can_delete(&self, user: &User, resource_path: &str) -> bool {
self.can_perform(user, &format!("DELETE {resource_path}"), None)
.await
}
pub async fn can_create(&self, user: &User, resource_path: &str) -> bool {
self.can_perform(user, &format!("POST {resource_path}"), None)
.await
}
pub async fn can_read(&self, user: &User, resource_path: &str) -> bool {
self.can_perform(user, &format!("GET {resource_path}"), None)
.await
}
#[must_use]
pub fn config(&self) -> &CedarConfig {
&self.config
}
}
#[cfg(feature = "cedar")]
fn build_resource() -> Result<EntityUid, CedarError> {
r#"Resource::"default""#
.parse()
.map_err(|e| CedarError::Internal(format!("Failed to parse resource: {e}")))
}
#[cfg(feature = "cedar")]
fn build_principal(user: &User) -> Result<EntityUid, CedarError> {
let principal_str = format!(r#"User::"{}""#, user.id);
let principal: EntityUid = principal_str
.parse()
.map_err(|e| CedarError::Internal(format!("Invalid principal: {e}")))?;
Ok(principal)
}
#[cfg(feature = "cedar")]
fn build_action_http(
method: &Method,
request: &Request<Body>,
path_normalizer: Option<fn(&str) -> String>,
) -> Result<EntityUid, CedarError> {
let normalized_path = request
.extensions()
.get::<MatchedPath>()
.map_or_else(
|| {
path_normalizer.map_or_else(
|| normalize_path_generic(request.uri().path()),
|normalizer| normalizer(request.uri().path()),
)
},
|matched| matched.as_str().to_string(),
);
let action_str = format!(r#"Action::"{method} {normalized_path}""#);
let action: EntityUid = action_str
.parse()
.map_err(|e| CedarError::Internal(format!("Invalid action: {e}")))?;
tracing::debug!(
method = %method,
path = %request.uri().path(),
normalized = %normalized_path,
action = %action,
"Built Cedar action"
);
Ok(action)
}
#[cfg(feature = "cedar")]
fn normalize_path_generic(path: &str) -> String {
let uuid_pattern =
regex::Regex::new(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
.expect("Invalid regex");
let path = uuid_pattern.replace_all(path, "{id}");
let numeric_pattern = regex::Regex::new(r"/(\d+)(/|$)").expect("Invalid regex");
let path = numeric_pattern.replace_all(&path, "/{id}${2}");
path.to_string()
}
#[cfg(feature = "cedar")]
fn build_context_http(headers: &HeaderMap, user: &User) -> Result<Context, CedarError> {
let mut context_map = serde_json::Map::new();
context_map.insert("roles".to_string(), json!(user.roles));
context_map.insert("permissions".to_string(), json!(user.permissions));
context_map.insert("email".to_string(), json!(user.email.as_str()));
context_map.insert("user_id".to_string(), json!(user.id));
context_map.insert("verified".to_string(), json!(user.email_verified));
let now = chrono::Utc::now();
context_map.insert(
"timestamp".to_string(),
json!({
"unix": now.timestamp(),
"hour": now.hour(),
"dayOfWeek": now.weekday().to_string(),
}),
);
if let Some(ip) = extract_client_ip(headers) {
context_map.insert("ip".to_string(), json!(ip));
}
if let Some(request_id) = headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
{
context_map.insert("requestId".to_string(), json!(request_id));
}
if let Some(user_agent) = headers.get("user-agent").and_then(|v| v.to_str().ok()) {
context_map.insert("userAgent".to_string(), json!(user_agent));
}
Context::from_json_value(serde_json::Value::Object(context_map), None)
.map_err(|e| CedarError::Internal(format!("Failed to build context: {e}")))
}
#[cfg(feature = "cedar")]
fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
return xff_str.split(',').next().map(|s| s.trim().to_string());
}
}
if let Some(xri) = headers.get("x-real-ip") {
if let Ok(xri_str) = xri.to_str() {
return Some(xri_str.to_string());
}
}
None
}
#[cfg(feature = "cedar")]
fn build_entities(user: &User) -> Result<Entities, CedarError> {
use serde_json::Value;
let entity = json!({
"uid": {
"type": "User",
"id": user.id.to_string()
},
"attrs": {
"email": user.email.as_str(),
"roles": user.roles.clone(),
"permissions": user.permissions.clone(),
"id": user.id,
"verified": user.email_verified,
},
"parents": []
});
Entities::from_json_value(Value::Array(vec![entity]), None)
.map_err(|e| CedarError::Internal(format!("Failed to build entities: {e}")))
}
#[cfg(feature = "cedar")]
fn parse_action_string(action: &str) -> Result<EntityUid, CedarError> {
let action_str = format!(r#"Action::"{action}""#);
action_str
.parse()
.map_err(|e| CedarError::Internal(format!("Failed to parse action '{action}': {e}")))
}
#[cfg(feature = "cedar")]
fn build_context_for_user(user: &User) -> Result<Context, CedarError> {
let mut context_map = serde_json::Map::new();
context_map.insert("roles".to_string(), json!(user.roles));
context_map.insert("permissions".to_string(), json!(user.permissions));
context_map.insert("email".to_string(), json!(user.email.as_str()));
context_map.insert("user_id".to_string(), json!(user.id));
context_map.insert("verified".to_string(), json!(user.email_verified));
let now = chrono::Utc::now();
context_map.insert(
"timestamp".to_string(),
json!({
"unix": now.timestamp(),
"hour": now.hour(),
"dayOfWeek": now.weekday().to_string(),
}),
);
Context::from_json_value(serde_json::Value::Object(context_map), None)
.map_err(|e| CedarError::Internal(format!("Failed to build context for user: {e}")))
}
#[cfg(test)]
#[cfg(feature = "cedar")]
mod tests {
use super::*;
#[test]
fn test_normalize_path_generic() {
assert_eq!(
normalize_path_generic("/api/v1/posts/123"),
"/api/v1/posts/{id}"
);
assert_eq!(
normalize_path_generic("/api/v1/posts/550e8400-e29b-41d4-a716-446655440000"),
"/api/v1/posts/{id}"
);
assert_eq!(normalize_path_generic("/api/v1/posts"), "/api/v1/posts");
}
#[test]
fn test_normalize_path_multiple_ids() {
assert_eq!(
normalize_path_generic("/api/posts/123/comments/456"),
"/api/posts/{id}/comments/{id}"
);
}
#[test]
fn test_parse_action_string() {
let result = parse_action_string("GET /posts");
assert!(result.is_ok());
let result = parse_action_string("PUT /posts/{id}");
assert!(result.is_ok());
let result = parse_action_string("DELETE /posts/{id}");
assert!(result.is_ok());
}
#[test]
fn test_build_principal() {
use crate::auth::user::EmailAddress;
let user = User {
id: 123,
email: EmailAddress::parse("test@example.com").unwrap(),
password_hash: "hash".to_string(),
roles: vec!["user".to_string()],
permissions: vec![],
email_verified: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let principal = build_principal(&user);
assert!(principal.is_ok());
let principal = principal.unwrap();
assert_eq!(principal.to_string(), r#"User::"123""#);
}
#[test]
fn test_build_context_for_user() {
use crate::auth::user::EmailAddress;
let user = User {
id: 123,
email: EmailAddress::parse("test@example.com").unwrap(),
password_hash: "hash".to_string(),
roles: vec!["user".to_string(), "moderator".to_string()],
permissions: vec!["read:posts".to_string()],
email_verified: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let context = build_context_for_user(&user);
assert!(context.is_ok());
}
#[test]
fn test_build_entities() {
use crate::auth::user::EmailAddress;
let user = User {
id: 123,
email: EmailAddress::parse("test@example.com").unwrap(),
password_hash: "hash".to_string(),
roles: vec!["user".to_string(), "admin".to_string()],
permissions: vec!["write:posts".to_string()],
email_verified: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let entities = build_entities(&user);
assert!(entities.is_ok());
}
#[test]
fn test_build_resource() {
let resource = build_resource();
assert!(resource.is_ok());
assert_eq!(resource.unwrap().to_string(), r#"Resource::"default""#);
}
}