fraiseql_server/config/error_sanitization.rs
1//! Error sanitization configuration and service.
2//!
3//! When `enabled = true`, strips internal error details (SQL fragments, stack
4//! traces, raw DB error messages) from GraphQL responses before they reach
5//! the client.
6
7use serde::Deserialize;
8
9use crate::error::{ErrorCode, GraphQLError};
10
11/// Configuration for error sanitization (mirrors `ErrorSanitizationConfig` from
12/// `fraiseql-cli`, deserialized from `compiled.security.error_sanitization`).
13#[derive(Debug, Clone, Deserialize)]
14#[serde(default)]
15pub struct ErrorSanitizationConfig {
16 /// Enable error sanitization (default: false — opt-in for backwards compat).
17 pub enabled: bool,
18 /// Strip stack traces, SQL fragments, file paths (default: true).
19 pub hide_implementation_details: bool,
20 /// Replace raw database error messages with a generic message (default: true).
21 pub sanitize_database_errors: bool,
22 /// Replacement message shown to clients when an internal error is sanitized.
23 pub custom_error_message: Option<String>,
24}
25
26impl Default for ErrorSanitizationConfig {
27 fn default() -> Self {
28 Self {
29 enabled: false,
30 hide_implementation_details: true,
31 sanitize_database_errors: true,
32 custom_error_message: None,
33 }
34 }
35}
36
37/// Sanitizes GraphQL errors before they reach the client.
38///
39/// When configured with `enabled = true`, strips internal details from
40/// `DatabaseError` and `InternalServerError` responses. Client-facing error
41/// codes (validation, auth, not-found, etc.) are always passed through
42/// unchanged so the client can act on them.
43pub struct ErrorSanitizer {
44 config: ErrorSanitizationConfig,
45}
46
47impl ErrorSanitizer {
48 /// Create a new sanitizer with the given configuration.
49 #[must_use]
50 pub const fn new(config: ErrorSanitizationConfig) -> Self {
51 Self { config }
52 }
53
54 /// Create a disabled sanitizer — current behaviour unchanged.
55 #[must_use]
56 pub fn disabled() -> Self {
57 Self::new(ErrorSanitizationConfig::default())
58 }
59
60 /// Sanitize a single GraphQL error.
61 ///
62 /// Returns the error unchanged when:
63 /// - sanitization is disabled, or
64 /// - the error code is client-facing (validation, auth, not-found, etc.)
65 #[must_use]
66 pub fn sanitize(&self, mut error: GraphQLError) -> GraphQLError {
67 if !self.config.enabled {
68 return error;
69 }
70
71 let is_internal =
72 matches!(error.code, ErrorCode::InternalServerError | ErrorCode::DatabaseError);
73
74 if is_internal && self.config.sanitize_database_errors {
75 error.message = self
76 .config
77 .custom_error_message
78 .clone()
79 .unwrap_or_else(|| "An internal error occurred".to_string());
80 }
81
82 if self.config.hide_implementation_details {
83 if let Some(ext) = error.extensions.as_mut() {
84 ext.detail = None;
85 }
86 }
87
88 error
89 }
90
91 /// Sanitize a batch of errors (the GraphQL `errors` response array).
92 #[must_use]
93 pub fn sanitize_all(&self, errors: Vec<GraphQLError>) -> Vec<GraphQLError> {
94 errors.into_iter().map(|e| self.sanitize(e)).collect()
95 }
96
97 /// Whether sanitization is enabled.
98 #[must_use]
99 pub const fn is_enabled(&self) -> bool {
100 self.config.enabled
101 }
102}