acton_htmx/handlers/
cedar_admin.rs

1//! Cedar policy administration handlers
2//!
3//! This module provides HTTP handlers for managing Cedar policies at runtime.
4//! These handlers should be protected with admin-only authorization.
5//!
6//! # Example Usage
7//!
8//! ```rust,ignore
9//! use acton_htmx::handlers::cedar_admin;
10//! use axum::Router;
11//!
12//! let admin_routes = Router::new()
13//!     .route("/admin/cedar/reload", post(cedar_admin::reload_policies))
14//!     .route("/admin/cedar/status", get(cedar_admin::policy_status));
15//! ```
16
17#[cfg(feature = "cedar")]
18use axum::{
19    extract::State,
20    http::StatusCode,
21    response::{IntoResponse, Response},
22    Json,
23};
24
25#[cfg(feature = "cedar")]
26use serde::{Deserialize, Serialize};
27
28#[cfg(feature = "cedar")]
29use crate::{
30    auth::{user::User, Authenticated},
31    middleware::cedar::CedarAuthz,
32};
33
34/// Response for policy reload endpoint
35#[cfg(feature = "cedar")]
36#[derive(Debug, Serialize, Deserialize)]
37pub struct ReloadPolicyResponse {
38    /// Whether the reload was successful
39    pub success: bool,
40
41    /// Success or error message
42    pub message: String,
43
44    /// Timestamp of reload (ISO 8601)
45    pub timestamp: String,
46}
47
48/// Response for policy status endpoint
49#[cfg(feature = "cedar")]
50#[derive(Debug, Serialize, Deserialize)]
51pub struct PolicyStatusResponse {
52    /// Whether Cedar authorization is enabled
53    pub enabled: bool,
54
55    /// Path to the policy file
56    pub policy_path: String,
57
58    /// Whether hot-reload is enabled
59    pub hot_reload: bool,
60
61    /// Failure mode (open or closed)
62    pub failure_mode: String,
63
64    /// Whether policy caching is enabled
65    pub cache_enabled: bool,
66}
67
68/// Reload Cedar policies from file
69///
70/// This endpoint allows administrators to reload Cedar policies without restarting the server.
71/// It should be protected with admin-only authorization.
72///
73/// # Requirements
74///
75/// - User must be authenticated
76/// - User must have "admin" role
77///
78/// # Errors
79///
80/// Returns [`StatusCode::FORBIDDEN`] if the authenticated user does not have the "admin" role.
81/// Returns [`StatusCode::INTERNAL_SERVER_ERROR`] if policy reload fails.
82///
83/// # Example
84///
85/// ```bash
86/// POST /admin/cedar/reload
87/// ```
88///
89/// Response:
90/// ```json
91/// {
92///   "success": true,
93///   "message": "Cedar policies reloaded successfully",
94///   "timestamp": "2025-11-22T10:30:00Z"
95/// }
96/// ```
97#[cfg(feature = "cedar")]
98pub async fn reload_policies(
99    State(cedar): State<CedarAuthz>,
100    Authenticated(user): Authenticated<User>,
101) -> Result<Response, StatusCode> {
102    // Verify user is admin
103    if !user.roles.contains(&"admin".to_string()) {
104        tracing::warn!(
105            user_id = user.id,
106            email = %user.email,
107            "Non-admin user attempted to reload Cedar policies"
108        );
109        return Err(StatusCode::FORBIDDEN);
110    }
111
112    // Attempt to reload policies
113    match cedar.reload_policies().await {
114        Ok(()) => {
115            let response = ReloadPolicyResponse {
116                success: true,
117                message: "Cedar policies reloaded successfully".to_string(),
118                timestamp: chrono::Utc::now().to_rfc3339(),
119            };
120
121            tracing::info!(
122                user_id = user.id,
123                email = %user.email,
124                "Cedar policies reloaded by admin"
125            );
126
127            Ok((StatusCode::OK, Json(response)).into_response())
128        }
129        Err(e) => {
130            tracing::error!(
131                error = ?e,
132                user_id = user.id,
133                "Failed to reload Cedar policies"
134            );
135
136            let response = ReloadPolicyResponse {
137                success: false,
138                message: format!("Failed to reload policies: {e}"),
139                timestamp: chrono::Utc::now().to_rfc3339(),
140            };
141
142            Ok((StatusCode::INTERNAL_SERVER_ERROR, Json(response)).into_response())
143        }
144    }
145}
146
147/// Get Cedar policy status
148///
149/// Returns information about the current Cedar configuration.
150/// This endpoint should be protected with admin-only authorization.
151///
152/// # Errors
153///
154/// Returns [`StatusCode::FORBIDDEN`] if the authenticated user does not have the "admin" role.
155///
156/// # Example
157///
158/// ```bash
159/// GET /admin/cedar/status
160/// ```
161///
162/// Response:
163/// ```json
164/// {
165///   "enabled": true,
166///   "policy_path": "policies/app.cedar",
167///   "hot_reload": false,
168///   "failure_mode": "closed",
169///   "cache_enabled": true
170/// }
171/// ```
172#[cfg(feature = "cedar")]
173pub async fn policy_status(
174    State(cedar): State<CedarAuthz>,
175    Authenticated(user): Authenticated<User>,
176) -> Result<Response, StatusCode> {
177    // Verify user is admin
178    if !user.roles.contains(&"admin".to_string()) {
179        tracing::warn!(
180            user_id = user.id,
181            email = %user.email,
182            "Non-admin user attempted to view Cedar policy status"
183        );
184        return Err(StatusCode::FORBIDDEN);
185    }
186
187    // Get policy status from config
188    let config = cedar.config();
189
190    let response = PolicyStatusResponse {
191        enabled: config.enabled,
192        policy_path: config.policy_path.display().to_string(),
193        hot_reload: config.hot_reload,
194        failure_mode: format!("{:?}", config.failure_mode).to_lowercase(),
195        cache_enabled: config.cache_enabled,
196    };
197
198    tracing::debug!(
199        user_id = user.id,
200        email = %user.email,
201        "Cedar policy status requested by admin"
202    );
203
204    Ok((StatusCode::OK, Json(response)).into_response())
205}
206
207#[cfg(test)]
208#[cfg(feature = "cedar")]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_reload_response_serialization() {
214        let response = ReloadPolicyResponse {
215            success: true,
216            message: "Policies reloaded".to_string(),
217            timestamp: "2025-11-22T10:30:00Z".to_string(),
218        };
219
220        let json = serde_json::to_string(&response).unwrap();
221        assert!(json.contains("\"success\":true"));
222        assert!(json.contains("Policies reloaded"));
223    }
224
225    #[test]
226    fn test_status_response_serialization() {
227        let response = PolicyStatusResponse {
228            enabled: true,
229            policy_path: "policies/app.cedar".to_string(),
230            hot_reload: false,
231            failure_mode: "closed".to_string(),
232            cache_enabled: true,
233        };
234
235        let json = serde_json::to_string(&response).unwrap();
236        assert!(json.contains("\"enabled\":true"));
237        assert!(json.contains("policies/app.cedar"));
238    }
239}