Skip to main content

fraiseql_server/routes/api/
admin.rs

1//! Admin API endpoints.
2//!
3//! Provides endpoints for:
4//! - Hot-reloading schema without restart
5//! - Invalidating cache by scope (all, entity type, or pattern)
6//! - Inspecting runtime configuration (sanitized)
7
8use std::{collections::HashMap, fs};
9
10use axum::{Json, extract::State};
11use fraiseql_core::{db::traits::DatabaseAdapter, schema::CompiledSchema};
12use serde::{Deserialize, Serialize};
13use tracing::{error, info};
14
15use crate::routes::{
16    api::types::{ApiError, ApiResponse},
17    graphql::AppState,
18};
19
20/// Current status of the query result cache as understood by the server.
21///
22/// Used in the admin config endpoint and startup logs to give operators
23/// an accurate picture of what `cache_enabled` actually activates.
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum CacheStatus {
28    /// `cache_enabled = false` — no cache guard or caching active.
29    Disabled,
30    /// `cache_enabled = true` — RLS safety guard is active, but full
31    /// query result caching (`CachedDatabaseAdapter`) is not yet wired.
32    #[deprecated(
33        since = "2.2.0",
34        note = "CachedDatabaseAdapter is now always wired when cache_enabled = true. \
35                Use `Active` or `Disabled` instead."
36    )]
37    RlsGuardOnly,
38    /// Full query result caching is active.
39    ///
40    /// `CachedDatabaseAdapter` is wired into the server when `cache_enabled = true`.
41    Active,
42}
43
44impl CacheStatus {
45    /// Derive cache status from the `cache_enabled` flag.
46    ///
47    /// # Deprecated
48    ///
49    /// Use `AppState::adapter_cache_enabled` to determine the true cache state.
50    #[must_use]
51    #[deprecated(
52        since = "2.2.0",
53        note = "Use `AppState::adapter_cache_enabled` to determine the true cache state. \
54                This function returns `RlsGuardOnly` which is no longer accurate."
55    )]
56    pub const fn from_cache_enabled(cache_enabled: bool) -> Self {
57        #[allow(deprecated)] // Reason: function itself is deprecated; returns deprecated variant
58        if cache_enabled {
59            Self::RlsGuardOnly
60        } else {
61            Self::Disabled
62        }
63    }
64}
65
66/// Request to reload schema from file.
67#[derive(Debug, Deserialize, Serialize)]
68pub struct ReloadSchemaRequest {
69    /// Path to compiled schema file
70    pub schema_path:   String,
71    /// If true, only validate the schema without applying changes
72    pub validate_only: bool,
73}
74
75/// Response after schema reload attempt.
76#[derive(Debug, Serialize)]
77pub struct ReloadSchemaResponse {
78    /// Whether the operation succeeded
79    pub success: bool,
80    /// Human-readable message about the result
81    pub message: String,
82}
83
84/// Request to clear cache entries.
85#[derive(Debug, Deserialize, Serialize)]
86pub struct CacheClearRequest {
87    /// Scope for clearing: "all", "entity", or "pattern"
88    pub scope:       String,
89    /// Entity type (required if scope is "entity")
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub entity_type: Option<String>,
92    /// Pattern (required if scope is "pattern")
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub pattern:     Option<String>,
95}
96
97/// Response after cache clear operation.
98#[derive(Debug, Serialize)]
99pub struct CacheClearResponse {
100    /// Whether the operation succeeded
101    pub success:         bool,
102    /// Number of entries cleared
103    pub entries_cleared: usize,
104    /// Human-readable message about the result
105    pub message:         String,
106}
107
108/// Response containing runtime configuration (sanitized).
109#[derive(Debug, Serialize)]
110pub struct AdminConfigResponse {
111    /// Server version
112    pub version: String,
113    /// Runtime configuration (secrets redacted)
114    pub config:  HashMap<String, String>,
115}
116
117/// Validate that a caller-supplied schema path is safe to open.
118///
119/// Two threats are guarded against:
120///
121/// 1. **Path traversal** — any component equal to `..` would let an attacker escape the intended
122///    directory, so such paths are rejected.
123/// 2. **Absolute-path escape** — when an `allowed_base` is given, the resolved path must start with
124///    that prefix.  An absolute path like `/etc/passwd` is rejected when the allowed base is
125///    `/var/fraiseql`.
126///
127/// # Errors
128///
129/// Returns `ApiError` with a validation error when the path is unsafe.
130pub fn validate_schema_path(
131    path: &str,
132    allowed_base: Option<&std::path::Path>,
133) -> Result<(), ApiError> {
134    use std::path::{Component, Path};
135
136    let p = Path::new(path);
137
138    // Reject any `..` component regardless of position.
139    if p.components().any(|c| c == Component::ParentDir) {
140        return Err(ApiError::validation_error(
141            "schema_path must not contain '..' (path traversal rejected)",
142        ));
143    }
144
145    // When an allowed base is configured, verify the path stays within it.
146    if let Some(base) = allowed_base {
147        // Build the candidate path: if relative, join onto base; if absolute keep as-is.
148        let candidate = if p.is_absolute() {
149            p.to_path_buf()
150        } else {
151            base.join(p)
152        };
153
154        // Use `starts_with` on the lexically resolved path.  We intentionally
155        // avoid `canonicalize` here to keep the function pure (no I/O), accepting
156        // that symlink escapes are a separate, deployment-level concern.
157        if !candidate.starts_with(base) {
158            return Err(ApiError::validation_error(
159                "schema_path is outside the allowed base directory",
160            ));
161        }
162    }
163
164    Ok(())
165}
166
167/// Reload schema from file.
168///
169/// Supports validation-only mode via `validate_only` flag.
170/// When applied, the schema is atomically swapped without stopping execution.
171///
172/// # Errors
173///
174/// Returns `ApiError` with a validation error if `schema_path` is empty.
175/// Returns `ApiError` with a validation error if `schema_path` contains path traversal.
176/// Returns `ApiError` with a parse error if the schema file cannot be read or parsed.
177///
178/// Requires admin token authentication.
179pub async fn reload_schema_handler<A: DatabaseAdapter>(
180    State(state): State<AppState<A>>,
181    Json(req): Json<ReloadSchemaRequest>,
182) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
183    let _ = &state; // used conditionally by #[cfg(feature = "arrow")]
184    if req.schema_path.is_empty() {
185        return Err(ApiError::validation_error("schema_path cannot be empty"));
186    }
187
188    // SECURITY: Reject path traversal and out-of-base absolute paths.
189    validate_schema_path(&req.schema_path, None)?;
190
191    // Step 1: Load schema from file
192    let schema_json = fs::read_to_string(&req.schema_path)
193        .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
194
195    // Step 2: Validate schema structure
196    let _validated_schema = CompiledSchema::from_json(&schema_json, false)
197        .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
198
199    if req.validate_only {
200        info!(
201            operation = "admin.reload_schema",
202            schema_path = %req.schema_path,
203            validate_only = true,
204            success = true,
205            "Admin: schema validation requested"
206        );
207        let response = ReloadSchemaResponse {
208            success: true,
209            message: "Schema validated successfully (not applied)".to_string(),
210        };
211        Ok(Json(ApiResponse {
212            status: "success".to_string(),
213            data:   response,
214        }))
215    } else {
216        // Step 3: Atomically swap the executor with the validated schema.
217        // We pass the already-validated JSON bytes to avoid re-reading from disk
218        // (prevents TOCTOU: the file could change between validation and reload).
219        let start = std::time::Instant::now();
220
221        match state.reload_schema_from_json(&schema_json).await {
222            Ok(()) => {
223                let duration_ms = start.elapsed().as_millis();
224                state
225                    .metrics
226                    .schema_reloads_total
227                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
228                info!(
229                    operation = "admin.reload_schema",
230                    schema_path = %req.schema_path,
231                    duration_ms,
232                    "Schema reloaded successfully"
233                );
234
235                let response = ReloadSchemaResponse {
236                    success: true,
237                    message: format!("Schema reloaded from {} in {duration_ms}ms", req.schema_path),
238                };
239                Ok(Json(ApiResponse {
240                    status: "success".to_string(),
241                    data:   response,
242                }))
243            },
244            Err(e) => {
245                state
246                    .metrics
247                    .schema_reload_errors_total
248                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
249                error!(
250                    operation = "admin.reload_schema",
251                    schema_path = %req.schema_path,
252                    error = %e,
253                    "Schema reload failed"
254                );
255                Err(ApiError::internal_error(format!("Schema reload failed: {e}")))
256            },
257        }
258    }
259}
260
261/// Cache statistics response.
262#[derive(Debug, Serialize)]
263pub struct CacheStatsResponse {
264    /// Number of entries currently in cache
265    pub entries_count: usize,
266    /// Whether cache is enabled
267    pub cache_enabled: bool,
268    /// Cache TTL in seconds
269    pub ttl_secs:      u64,
270    /// Human-readable message
271    pub message:       String,
272}
273
274/// Clear cache entries by scope.
275///
276/// Supports three clearing scopes:
277/// - **all**: Clear all cache entries
278/// - **entity**: Clear entries for a specific entity type
279/// - **pattern**: Clear entries matching a glob pattern
280///
281/// # Errors
282///
283/// Returns `ApiError` with an internal error if the cache feature is not enabled.
284/// Returns `ApiError` with a validation error if required parameters are missing or scope is
285/// invalid.
286///
287/// Requires admin token authentication.
288pub async fn cache_clear_handler<A: DatabaseAdapter>(
289    State(state): State<AppState<A>>,
290    Json(req): Json<CacheClearRequest>,
291) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
292    // Cache operations require the `arrow` feature.
293    #[cfg(not(feature = "arrow"))]
294    {
295        let _ = (state, req);
296        Err(ApiError::internal_error("Cache not configured"))
297    }
298
299    #[cfg(feature = "arrow")]
300    // Validate scope and required parameters
301    match req.scope.as_str() {
302        "all" => {
303            if let Some(cache) = state.cache() {
304                let entries_before = cache.len();
305                cache.clear();
306                info!(
307                    operation = "admin.cache_clear",
308                    scope = "all",
309                    entries_cleared = entries_before,
310                    success = true,
311                    "Admin: cache cleared (all entries)"
312                );
313                let response = CacheClearResponse {
314                    success:         true,
315                    entries_cleared: entries_before,
316                    message:         format!("Cleared {} cache entries", entries_before),
317                };
318                Ok(Json(ApiResponse {
319                    status: "success".to_string(),
320                    data:   response,
321                }))
322            } else {
323                Err(ApiError::internal_error("Cache not configured"))
324            }
325        },
326        "entity" => {
327            if req.entity_type.is_none() {
328                return Err(ApiError::validation_error(
329                    "entity_type is required when scope is 'entity'",
330                ));
331            }
332
333            if let Some(cache) = state.cache() {
334                let entity_type = req.entity_type.as_ref().ok_or_else(|| {
335                    ApiError::internal_error(
336                        "entity_type was None after validation — this is a bug",
337                    )
338                })?;
339                // Convert entity type to view name pattern (e.g., User → v_user)
340                let view_name = format!("v_{}", entity_type.to_lowercase());
341                let entries_cleared = cache.invalidate_views(&[&view_name]);
342                info!(
343                    operation = "admin.cache_clear",
344                    scope = "entity",
345                    entity_type = %entity_type,
346                    entries_cleared,
347                    success = true,
348                    "Admin: cache cleared for entity"
349                );
350                let response = CacheClearResponse {
351                    success: true,
352                    entries_cleared,
353                    message: format!(
354                        "Cleared {} cache entries for entity type '{}'",
355                        entries_cleared, entity_type
356                    ),
357                };
358                Ok(Json(ApiResponse {
359                    status: "success".to_string(),
360                    data:   response,
361                }))
362            } else {
363                Err(ApiError::internal_error("Cache not configured"))
364            }
365        },
366        "pattern" => {
367            if req.pattern.is_none() {
368                return Err(ApiError::validation_error(
369                    "pattern is required when scope is 'pattern'",
370                ));
371            }
372
373            if let Some(cache) = state.cache() {
374                let pattern = req.pattern.as_ref().ok_or_else(|| {
375                    ApiError::internal_error("pattern was None after validation — this is a bug")
376                })?;
377                let entries_cleared = cache.invalidate_pattern(pattern);
378                info!(
379                    operation = "admin.cache_clear",
380                    scope = "pattern",
381                    %pattern,
382                    entries_cleared,
383                    success = true,
384                    "Admin: cache cleared by pattern"
385                );
386                let response = CacheClearResponse {
387                    success: true,
388                    entries_cleared,
389                    message: format!(
390                        "Cleared {} cache entries matching pattern '{}'",
391                        entries_cleared, pattern
392                    ),
393                };
394                Ok(Json(ApiResponse {
395                    status: "success".to_string(),
396                    data:   response,
397                }))
398            } else {
399                Err(ApiError::internal_error("Cache not configured"))
400            }
401        },
402        _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
403    }
404}
405
406/// Get cache statistics.
407///
408/// Returns current cache metrics including entry count, enabled status, and TTL.
409///
410/// # Errors
411///
412/// This handler currently always succeeds; it is infallible.
413///
414/// Requires admin token authentication.
415pub async fn cache_stats_handler<A: DatabaseAdapter>(
416    State(state): State<AppState<A>>,
417) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
418    #[cfg(feature = "arrow")]
419    if let Some(cache) = state.cache() {
420        let response = CacheStatsResponse {
421            entries_count: cache.len(),
422            cache_enabled: true,
423            ttl_secs:      60, // Default TTL from QueryCache::new(60)
424            message:       format!("Cache contains {} entries with 60-second TTL", cache.len()),
425        };
426        return Ok(Json(ApiResponse {
427            status: "success".to_string(),
428            data:   response,
429        }));
430    }
431    {
432        let _ = state;
433        let response = CacheStatsResponse {
434            entries_count: 0,
435            cache_enabled: false,
436            ttl_secs:      0,
437            message:       "Cache is not configured".to_string(),
438        };
439        Ok(Json(ApiResponse {
440            status: "success".to_string(),
441            data:   response,
442        }))
443    }
444}
445
446/// Get sanitized runtime configuration.
447///
448/// Returns server version and runtime configuration with secrets redacted.
449/// Configuration includes database settings, cache settings, etc.
450/// but excludes API keys, passwords, and other sensitive data.
451///
452/// # Errors
453///
454/// This handler currently always succeeds; it is infallible.
455///
456/// Requires admin token authentication.
457// Reason: `cache_enabled = "false"` appears in both the else-branch and the
458// `#[cfg(not(feature = "arrow"))]` inner path. Clippy sees them as shared code, but
459// extracting it would break the `#[cfg]` conditional logic that sets a different value
460// when `arrow` is enabled.
461#[allow(clippy::branches_sharing_code)] // Reason: branches are logically distinct; extracting shared code would obscure intent
462pub async fn config_handler<A: DatabaseAdapter>(
463    State(state): State<AppState<A>>,
464) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
465    let mut config = HashMap::new();
466
467    // Get actual server configuration
468    if let Some(server_config) = state.server_config() {
469        // Safe configuration values - no secrets
470        config.insert("port".to_string(), server_config.port.to_string());
471        config.insert("host".to_string(), server_config.host.clone());
472
473        if let Some(workers) = server_config.workers {
474            config.insert("workers".to_string(), workers.to_string());
475        }
476
477        // TLS status (boolean only, paths are redacted)
478        config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
479
480        // Request limits
481        if let Some(limits) = &server_config.limits {
482            config.insert("max_request_size".to_string(), limits.max_request_size.clone());
483            config.insert("request_timeout".to_string(), limits.request_timeout.clone());
484            config.insert(
485                "max_concurrent_requests".to_string(),
486                limits.max_concurrent_requests.to_string(),
487            );
488            config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
489        }
490
491        // Cache status: read from adapter_cache_enabled (set at startup by ServerBuilder).
492        // This reflects the CachedDatabaseAdapter state, independent of the Arrow cache.
493        let cache_active = state.adapter_cache_enabled;
494
495        config.insert("cache_enabled".to_string(), cache_active.to_string());
496        let cache_status = if cache_active {
497            CacheStatus::Active
498        } else {
499            CacheStatus::Disabled
500        };
501        config.insert(
502            "cache_status".to_string(),
503            serde_json::to_string(&cache_status)
504                .unwrap_or_else(|_| "\"disabled\"".to_string())
505                .trim_matches('"')
506                .to_string(),
507        );
508        let _ = server_config; // consumed above for other fields
509    } else {
510        // Minimal configuration if not available
511        config.insert("cache_enabled".to_string(), "false".to_string());
512        config.insert("cache_status".to_string(), "disabled".to_string());
513    }
514
515    let response = AdminConfigResponse {
516        version: env!("CARGO_PKG_VERSION").to_string(),
517        config,
518    };
519
520    Ok(Json(ApiResponse {
521        status: "success".to_string(),
522        data:   response,
523    }))
524}
525
526/// Request body for `POST /api/v1/admin/explain`.
527#[derive(Debug, Deserialize, Serialize)]
528pub struct ExplainRequest {
529    /// Name of the regular query to explain (e.g., `"users"`).
530    pub query: String,
531
532    /// GraphQL-style variable filters passed as a JSON object.
533    ///
534    /// Each key-value pair becomes an equality condition in the WHERE clause.
535    /// Example: `{"status": "active"}` → `WHERE data->>'status' = 'active'`.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub variables: Option<serde_json::Value>,
538
539    /// Optional row limit to pass to the query.
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub limit: Option<u32>,
542
543    /// Optional row offset to pass to the query.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub offset: Option<u32>,
546}
547
548/// Return the pre-built Grafana dashboard JSON for FraiseQL metrics.
549///
550/// The dashboard JSON is embedded at compile time from
551/// `deploy/grafana/fraiseql-dashboard.json`.  Operators can import it into
552/// Grafana with a single `curl` command (see `deploy/grafana/README.md`).
553///
554/// # Errors
555///
556/// This handler is infallible — the embedded JSON is validated at compile time
557/// by the `test_grafana_dashboard_is_valid_json` unit test.
558///
559/// Requires admin token authentication.
560pub async fn grafana_dashboard_handler<A: DatabaseAdapter>(
561    State(_state): State<AppState<A>>,
562) -> impl axum::response::IntoResponse {
563    const DASHBOARD_JSON: &str = include_str!("../../../resources/fraiseql-dashboard.json");
564
565    (
566        axum::http::StatusCode::OK,
567        [(axum::http::header::CONTENT_TYPE, "application/json")],
568        DASHBOARD_JSON,
569    )
570}
571
572/// Run `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for a named query.
573///
574/// Accepts a query name and optional variable filters, then executes
575/// `EXPLAIN ANALYZE` against the backing PostgreSQL view using the exact
576/// same parameterized SQL that a live query would use.
577///
578/// # Errors
579///
580/// * `400 Bad Request` — empty query name, unknown query, or mutation given
581/// * `500 Internal Server Error` — database execution failure
582///
583/// Requires admin token authentication.
584pub async fn explain_handler<A: DatabaseAdapter + 'static>(
585    State(state): State<AppState<A>>,
586    Json(req): Json<ExplainRequest>,
587) -> Result<Json<ApiResponse<fraiseql_core::runtime::ExplainResult>>, ApiError> {
588    if req.query.is_empty() {
589        return Err(ApiError::validation_error("query cannot be empty"));
590    }
591
592    state
593        .executor()
594        .explain(&req.query, req.variables.as_ref(), req.limit, req.offset)
595        .await
596        .map(ApiResponse::success)
597        .map_err(|e| match e {
598            fraiseql_core::error::FraiseQLError::Validation { message, .. } => {
599                ApiError::validation_error(message)
600            },
601            fraiseql_core::error::FraiseQLError::Unsupported { message } => {
602                ApiError::validation_error(format!("Unsupported: {message}"))
603            },
604            other => ApiError::internal_error(other.to_string()),
605        })
606}