acton_htmx/middleware/
cedar.rs

1//! Cedar authorization middleware for acton-htmx
2//!
3//! This middleware integrates AWS Cedar policy-based authorization into acton-htmx.
4//! It validates authorization requests against Cedar policies after session authentication.
5//!
6//! # Key Differences from acton-service
7//!
8//! - **Session-based auth**: Extracts principal from User (via session), not JWT claims
9//! - **HTMX responses**: Returns HTMX partials for 403 errors instead of full pages
10//! - **Template integration**: Provides helpers for authorization checks in templates
11//!
12//! # Example Usage
13//!
14//! ```rust,ignore
15//! use acton_htmx::middleware::cedar::CedarAuthz;
16//! use acton_htmx::config::CedarConfig;
17//!
18//! let cedar = CedarAuthz::from_config(cedar_config).await?;
19//!
20//! let app = Router::new()
21//!     .route("/posts/:id", put(update_post))
22//!     .layer(axum::middleware::from_fn_with_state(cedar.clone(), CedarAuthz::middleware));
23//! ```
24
25#[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/// Cedar authorization errors
58#[cfg(feature = "cedar")]
59#[derive(Debug, Error)]
60pub enum CedarError {
61    /// Configuration error
62    #[error("Cedar configuration error: {0}")]
63    Config(String),
64
65    /// Policy file error
66    #[error("Policy file error: {0}")]
67    PolicyFile(String),
68
69    /// Policy parsing error
70    #[error("Policy parsing error: {0}")]
71    PolicyParsing(String),
72
73    /// Authorization denied
74    #[error("Authorization denied: {0}")]
75    Forbidden(String),
76
77    /// Unauthorized (not authenticated)
78    #[error("Unauthorized: {0}")]
79    Unauthorized(String),
80
81    /// Internal error
82    #[error("Internal error: {0}")]
83    Internal(String),
84
85    /// IO error
86    #[error("IO error: {0}")]
87    Io(#[from] std::io::Error),
88
89    /// Join error
90    #[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        // Lazily initialize templates for error page rendering
102        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        // For unauthorized (not authenticated), redirect to login via HX-Redirect header
125        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        // Render error page using framework templates
134        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/// Builder for Cedar authorization middleware
153///
154/// Use this to construct a `CedarAuthz` instance with custom configuration.
155///
156/// # Examples
157///
158/// Simple case (defaults):
159/// ```rust,ignore
160/// let cedar = CedarAuthz::builder(cedar_config)
161///     .build()
162///     .await?;
163/// ```
164///
165/// With custom path normalizer:
166/// ```rust,ignore
167/// let cedar = CedarAuthz::builder(cedar_config)
168///     .with_path_normalizer(normalize_fn)
169///     .build()
170///     .await?;
171/// ```
172#[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    /// Create a new builder with the given configuration
181    #[must_use]
182    pub fn new(config: CedarConfig) -> Self {
183        Self {
184            config,
185            path_normalizer: None,
186        }
187    }
188
189    /// Set a custom path normalizer
190    ///
191    /// By default, Cedar uses a generic path normalizer that replaces UUIDs and numeric IDs
192    /// with `{id}` placeholders. Use this method to provide custom normalization logic for
193    /// your application's specific path patterns.
194    ///
195    /// # Example
196    ///
197    /// ```rust,ignore
198    /// fn custom_normalizer(path: &str) -> String {
199    ///     // Example: /articles/my-article-slug-123 -> /articles/{slug}
200    ///     path.replace("/articles/", "/articles/{slug}/")
201    /// }
202    ///
203    /// let cedar = CedarAuthz::builder(cedar_config)
204    ///     .with_path_normalizer(custom_normalizer)
205    ///     .build()
206    ///     .await?;
207    /// ```
208    #[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    /// Build the CedarAuthz instance (async)
215    ///
216    /// This loads the Cedar policies from the configured file path.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`CedarError`] if:
221    /// - Policy file cannot be read (file not found, permission denied)
222    /// - Policy file contains invalid Cedar syntax
223    /// - Policy parsing fails
224    /// - Async file I/O task panics or is cancelled
225    pub async fn build(self) -> Result<CedarAuthz, CedarError> {
226        // Load policies from file (using spawn_blocking for file I/O)
227        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/// Cedar authorization middleware state
245#[cfg(feature = "cedar")]
246#[derive(Clone)]
247pub struct CedarAuthz {
248    /// Cedar authorizer (stateless evaluator)
249    authorizer: Arc<Authorizer>,
250
251    /// Cedar policy set (policies loaded from file)
252    policy_set: Arc<RwLock<PolicySet>>,
253
254    /// Configuration
255    config: Arc<CedarConfig>,
256
257    /// Custom path normalizer (optional, defaults to normalize_path_generic)
258    path_normalizer: Option<fn(&str) -> String>,
259}
260
261#[cfg(feature = "cedar")]
262impl CedarAuthz {
263    /// Create a builder for CedarAuthz
264    ///
265    /// This is the recommended way to construct CedarAuthz instances.
266    ///
267    /// # Example
268    ///
269    /// ```rust,ignore
270    /// let cedar = CedarAuthz::builder(cedar_config)
271    ///     .with_path_normalizer(normalize_fn)
272    ///     .build()
273    ///     .await?;
274    /// ```
275    #[must_use]
276    pub fn builder(config: CedarConfig) -> CedarAuthzBuilder {
277        CedarAuthzBuilder::new(config)
278    }
279
280    /// Create CedarAuthz from config with defaults (convenience method)
281    ///
282    /// This is a shortcut for `CedarAuthz::builder(config).build().await`.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`CedarError`] if policy loading or parsing fails.
287    /// See [`CedarAuthzBuilder::build`] for detailed error conditions.
288    ///
289    /// # Example
290    ///
291    /// ```rust,ignore
292    /// let cedar = CedarAuthz::from_config(cedar_config).await?;
293    /// ```
294    pub async fn from_config(config: CedarConfig) -> Result<Self, CedarError> {
295        Self::builder(config).build().await
296    }
297
298    /// Middleware function to evaluate Cedar policies
299    ///
300    /// This middleware:
301    /// 1. Skips if Cedar is disabled
302    /// 2. Skips health/ready endpoints
303    /// 3. Extracts User from session (inserted by session middleware)
304    /// 4. Builds Cedar principal, action, context
305    /// 5. Evaluates policies
306    /// 6. Returns 403 if denied, continues if allowed
307    ///
308    /// # Errors
309    ///
310    /// Returns [`CedarError`] if:
311    /// - User session is missing (session middleware must run first)
312    /// - Policy evaluation fails
313    /// - Authorization is denied by Cedar policies
314    /// - Entity or context building fails
315    #[allow(clippy::cognitive_complexity)] // Middleware with multiple validation steps
316    pub async fn middleware(
317        State(authz): State<Self>,
318        request: Request<Body>,
319        next: Next,
320    ) -> Result<Response, CedarError> {
321        // Skip if Cedar is disabled
322        if !authz.config.enabled {
323            return Ok(next.run(request).await);
324        }
325
326        // Skip authorization for health and readiness endpoints
327        let path = request.uri().path();
328        if path == "/health" || path == "/ready" {
329            return Ok(next.run(request).await);
330        }
331
332        // Extract User from request extensions (inserted by session middleware)
333        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        // Extract request information
341        let method = request.method().clone();
342
343        // Build Cedar authorization request
344        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        // Build resource (generic default for now)
349        let resource = build_resource()?;
350
351        let cedar_request = CedarRequest::new(
352            principal.clone(),
353            action.clone(),
354            resource.clone(),
355            context,
356            None, // Schema: None (optional)
357        )
358        .map_err(|e| CedarError::Internal(format!("Failed to build Cedar request: {e}")))?;
359
360        // Evaluate policies
361        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        // Debug logging: Log all policy evaluations in debug mode
370        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        // Handle decision
386        match response.decision() {
387            Decision::Allow => {
388                // Log successful authorization at trace level
389                tracing::trace!(
390                    principal = ?principal,
391                    action = ?action,
392                    user_id = user.id,
393                    "Cedar policy allowed request"
394                );
395
396                // Allow request to proceed
397                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    /// Reload policies from file (for hot-reload support)
423    ///
424    /// # Errors
425    ///
426    /// Returns [`CedarError`] if:
427    /// - Policy file cannot be read
428    /// - Policy file contains invalid Cedar syntax
429    /// - Policy parsing fails
430    /// - Async file I/O task panics or is cancelled
431    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    /// Check if a user can perform a specific action
452    ///
453    /// This is a programmatic helper for authorization checks in handlers and templates.
454    /// It evaluates Cedar policies for the given user, action string, and optional resource.
455    ///
456    /// # Arguments
457    ///
458    /// * `user` - The authenticated user
459    /// * `action` - Action string in the format "METHOD /path" (e.g., "PUT /posts/{id}")
460    /// * `resource_id` - Optional resource ID for ownership checks (future use)
461    ///
462    /// # Returns
463    ///
464    /// Returns `true` if the user is authorized, `false` otherwise.
465    ///
466    /// # Examples
467    ///
468    /// ```rust,ignore
469    /// // In a handler
470    /// if cedar.can_perform(user, "DELETE /posts/{id}", None).await {
471    ///     // User can delete posts
472    /// }
473    ///
474    /// // With resource ownership check (future)
475    /// if cedar.can_perform(user, "PUT /posts/{id}", Some(post_id)).await {
476    ///     // User can update this specific post
477    /// }
478    /// ```
479    #[allow(clippy::cognitive_complexity)] // Multiple Cedar request building steps
480    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 Cedar is disabled, allow all actions
487        if !self.config.enabled {
488            return true;
489        }
490
491        // Build Cedar request components
492        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        // Build resource (generic for now, can be extended for typed resources)
509        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        // Build context with user attributes
518        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        // Create Cedar authorization request
527        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        // Build entities
536        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        // Evaluate policies
545        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        // Return true if allowed, false otherwise
552        matches!(response.decision(), Decision::Allow)
553    }
554
555    /// Convenience method: Check if user can update a resource
556    ///
557    /// Checks if the user can perform a PUT operation on the given resource path.
558    ///
559    /// # Examples
560    ///
561    /// ```rust,ignore
562    /// if cedar.can_update(user, "/posts/{id}").await {
563    ///     // Show edit button
564    /// }
565    /// ```
566    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    /// Convenience method: Check if user can delete a resource
572    ///
573    /// Checks if the user can perform a DELETE operation on the given resource path.
574    ///
575    /// # Examples
576    ///
577    /// ```rust,ignore
578    /// if cedar.can_delete(user, "/posts/{id}").await {
579    ///     // Show delete button
580    /// }
581    /// ```
582    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    /// Convenience method: Check if user can create a resource
588    ///
589    /// Checks if the user can perform a POST operation on the given resource path.
590    ///
591    /// # Examples
592    ///
593    /// ```rust,ignore
594    /// if cedar.can_create(user, "/posts").await {
595    ///     // Show create button
596    /// }
597    /// ```
598    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    /// Convenience method: Check if user can read a resource
604    ///
605    /// Checks if the user can perform a GET operation on the given resource path.
606    ///
607    /// # Examples
608    ///
609    /// ```rust,ignore
610    /// if cedar.can_read(user, "/posts/{id}").await {
611    ///     // Show content
612    /// }
613    /// ```
614    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    /// Get a reference to the Cedar configuration
620    ///
621    /// This method provides read-only access to the Cedar configuration.
622    /// Useful for admin endpoints that need to display current configuration.
623    #[must_use]
624    pub fn config(&self) -> &CedarConfig {
625        &self.config
626    }
627}
628
629/// Build Cedar resource entity
630///
631/// Returns a generic default resource for authorization checks.
632/// Most authorization policies can be implemented using just the principal (user/roles)
633/// and action (HTTP method + path), without needing typed resources.
634///
635/// For applications that need typed resources with attributes (e.g., Post::"123"
636/// with author_id for ownership checks), this can be extended in the future.
637#[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/// Build Cedar principal from User (session-based auth)
645///
646/// Extracts principal from authenticated User, not JWT claims.
647/// Principal format: User::"123" (user ID)
648#[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/// Build Cedar action from HTTP method and request
660///
661/// Uses Axum's MatchedPath to get the route pattern (most accurate).
662/// Falls back to path normalization (custom or default) if MatchedPath is not available.
663#[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    // Try to get Axum's matched path first (e.g., "/posts/:id")
670    let normalized_path = request
671        .extensions()
672        .get::<MatchedPath>()
673        .map_or_else(
674            || {
675                // Use custom normalizer if provided, otherwise use default
676                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    // Debug logging to see what action was generated
691    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/// Normalize path by replacing common ID patterns with placeholders
703///
704/// This is a generic fallback used when Axum's MatchedPath is not available.
705/// It handles the most common ID patterns:
706/// - UUIDs: replaced with {id}
707/// - Numeric IDs: replaced with {id}
708#[cfg(feature = "cedar")]
709fn normalize_path_generic(path: &str) -> String {
710    // Replace UUIDs with {id}
711    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    // Replace numeric IDs at end of path segments
717    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/// Build Cedar context from HTTP headers and user
724#[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    // Add user roles
729    context_map.insert("roles".to_string(), json!(user.roles));
730
731    // Add user permissions
732    context_map.insert("permissions".to_string(), json!(user.permissions));
733
734    // Add user email
735    context_map.insert("email".to_string(), json!(user.email.as_str()));
736
737    // Add user ID
738    context_map.insert("user_id".to_string(), json!(user.id));
739
740    // Add email verification status
741    context_map.insert("verified".to_string(), json!(user.email_verified));
742
743    // Add timestamp
744    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    // Add IP address (from X-Forwarded-For or X-Real-IP)
755    if let Some(ip) = extract_client_ip(headers) {
756        context_map.insert("ip".to_string(), json!(ip));
757    }
758
759    // Add request ID if present
760    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    // Add user-agent if present
768    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/// Extract client IP from headers
777#[cfg(feature = "cedar")]
778fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
779    // Try X-Forwarded-For header first (for proxied requests)
780    if let Some(xff) = headers.get("x-forwarded-for") {
781        if let Ok(xff_str) = xff.to_str() {
782            // Take first IP in comma-separated list
783            return xff_str.split(',').next().map(|s| s.trim().to_string());
784        }
785    }
786
787    // Try X-Real-IP header
788    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/// Build entity hierarchy from user
798///
799/// Creates the principal entity (User) with roles and permissions.
800#[cfg(feature = "cedar")]
801fn build_entities(user: &User) -> Result<Entities, CedarError> {
802    use serde_json::Value;
803
804    // Create principal entity (User) with attributes
805    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/// Parse action string into Cedar EntityUid
825///
826/// Takes action string like "PUT /posts/{id}" and converts it to Cedar action entity.
827#[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/// Build Cedar context from user alone (for programmatic checks)
836///
837/// Similar to `build_context_http` but without HTTP headers.
838/// Used by `can_perform` and template helpers.
839#[cfg(feature = "cedar")]
840fn build_context_for_user(user: &User) -> Result<Context, CedarError> {
841    let mut context_map = serde_json::Map::new();
842
843    // Add user roles
844    context_map.insert("roles".to_string(), json!(user.roles));
845
846    // Add user permissions
847    context_map.insert("permissions".to_string(), json!(user.permissions));
848
849    // Add user email
850    context_map.insert("email".to_string(), json!(user.email.as_str()));
851
852    // Add user ID
853    context_map.insert("user_id".to_string(), json!(user.id));
854
855    // Add email verification status
856    context_map.insert("verified".to_string(), json!(user.email_verified));
857
858    // Add timestamp (current time for programmatic checks)
859    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    // Integration tests for policy evaluation would require a full Cedar setup
979    // with policy files and async runtime, so they should be in integration tests
980}