Skip to main content

fraiseql_core/security/
errors.rs

1//! Security-specific error types for comprehensive error handling.
2//!
3//! This module defines all security-related error types used throughout
4//! the framework. No `PyO3` decorators - all types are pure Rust.
5//!
6//! Note: The `PyO3` FFI wrappers for Python are in `py/src/ffi/errors.rs`
7
8use std::fmt;
9
10/// Main security error type for all security operations.
11///
12/// Covers rate limiting, query validation, CORS, CSRF, audit logging,
13/// and security configuration errors.
14#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub enum SecurityError {
17    /// Rate limiting exceeded - client has made too many requests.
18    ///
19    /// Contains:
20    /// - `retry_after`: Seconds to wait before retrying
21    /// - `limit`: Maximum allowed requests
22    /// - `window_secs`: Time window in seconds
23    RateLimitExceeded {
24        /// Seconds to wait before retrying
25        retry_after: u64,
26        /// Maximum allowed requests
27        limit:       usize,
28        /// Time window in seconds
29        window_secs: u64,
30    },
31
32    /// Query validation: depth exceeds maximum allowed.
33    ///
34    /// GraphQL queries can nest arbitrarily deep, which can cause
35    /// excessive database queries or resource consumption.
36    QueryTooDeep {
37        /// Actual query depth
38        depth:     usize,
39        /// Maximum allowed depth
40        max_depth: usize,
41    },
42
43    /// Query validation: complexity exceeds configured limit.
44    ///
45    /// Complexity is calculated as a weighted sum of field costs,
46    /// accounting for pagination and nested selections.
47    QueryTooComplex {
48        /// Actual query complexity score
49        complexity:     usize,
50        /// Maximum allowed complexity
51        max_complexity: usize,
52    },
53
54    /// Query validation: size exceeds maximum allowed bytes.
55    ///
56    /// Very large queries can consume memory or cause `DoS`.
57    QueryTooLarge {
58        /// Actual query size in bytes
59        size:     usize,
60        /// Maximum allowed size in bytes
61        max_size: usize,
62    },
63
64    /// CORS origin not in allowed list.
65    OriginNotAllowed(String),
66
67    /// CORS HTTP method not allowed.
68    MethodNotAllowed(String),
69
70    /// CORS header not in allowed list.
71    HeaderNotAllowed(String),
72
73    /// CSRF token validation failed.
74    InvalidCSRFToken(String),
75
76    /// CSRF token session ID mismatch.
77    CSRFSessionMismatch,
78
79    /// Audit log write failure.
80    ///
81    /// Audit logging to the database failed. The underlying
82    /// reason is captured in the error string.
83    AuditLogFailure(String),
84
85    /// Security configuration error.
86    ///
87    /// The security configuration is invalid or incomplete.
88    SecurityConfigError(String),
89
90    /// TLS/HTTPS required but connection is not secure.
91    ///
92    /// The security profile requires all connections to be HTTPS/TLS,
93    /// but an HTTP connection was received.
94    TlsRequired {
95        /// Description of what was required
96        detail: String,
97    },
98
99    /// TLS version is below the minimum required version.
100    ///
101    /// The connection uses TLS but the version is too old. For example,
102    /// if TLS 1.3 is required but the connection uses TLS 1.2.
103    TlsVersionTooOld {
104        /// The TLS version actually used
105        current:  crate::security::TlsVersion,
106        /// The minimum TLS version required
107        required: crate::security::TlsVersion,
108    },
109
110    /// Mutual TLS (client certificate) required but not provided.
111    ///
112    /// The security profile requires mTLS, meaning clients must present
113    /// a valid X.509 certificate, but none was provided.
114    MtlsRequired {
115        /// Description of what was required
116        detail: String,
117    },
118
119    /// Client certificate validation failed.
120    ///
121    /// A client certificate was presented, but it failed validation.
122    /// This could be due to an invalid signature, expired certificate,
123    /// revoked certificate, or other validation errors.
124    InvalidClientCert {
125        /// Description of why validation failed
126        detail: String,
127    },
128
129    /// Authentication is required but none was provided.
130    ///
131    /// Used in auth middleware when authentication is required
132    /// (configured or policy enforces it) but no valid credentials
133    /// were found in the request.
134    AuthRequired,
135
136    /// Authentication token is invalid or malformed.
137    ///
138    /// The provided authentication token (e.g., JWT) failed to parse
139    /// or validate. Could be due to invalid signature, bad format, etc.
140    InvalidToken,
141
142    /// JWT signature verification failed.
143    ///
144    /// The token's signature does not match the configured signing secret.
145    /// Most commonly caused by a wrong or rotated `FRAISEQL_JWT_SECRET`.
146    JwtSignatureInvalid,
147
148    /// JWT issuer claim does not match the expected issuer.
149    ///
150    /// The token was issued by a different service than expected.
151    /// Check `[security.jwt] issuer` in fraiseql.toml.
152    JwtIssuerMismatch {
153        /// The expected issuer from configuration
154        expected: String,
155    },
156
157    /// JWT audience claim does not match the expected audience.
158    ///
159    /// The token was issued for a different service or audience.
160    /// Check `[security.jwt] audience` in fraiseql.toml.
161    JwtAudienceMismatch {
162        /// The expected audience from configuration
163        expected: String,
164    },
165
166    /// Authentication token has expired.
167    ///
168    /// The authentication token has an 'exp' claim and that timestamp
169    /// has passed. The user needs to re-authenticate.
170    TokenExpired {
171        /// The time when the token expired
172        expired_at: chrono::DateTime<chrono::Utc>,
173    },
174
175    /// Authentication token is missing a required claim.
176    ///
177    /// The authentication token doesn't have a required claim like 'sub', 'exp', etc.
178    TokenMissingClaim {
179        /// The name of the claim that's missing
180        claim: String,
181    },
182
183    /// Authentication token algorithm doesn't match expected algorithm.
184    ///
185    /// The token was signed with a different algorithm than expected
186    /// (e.g., token used HS256 but system expects RS256).
187    InvalidTokenAlgorithm {
188        /// The algorithm used in the token
189        algorithm: String,
190    },
191
192    /// JWT token has been replayed — its `jti` (JWT ID) was already used.
193    ///
194    /// A previously-validated token is being reused. This indicates a
195    /// stolen-token replay attack. The token should be rejected and the event
196    /// should trigger a security alert.
197    TokenReplayed,
198
199    /// GraphQL introspection query is not allowed.
200    ///
201    /// The security policy disallows introspection queries (__schema, __type),
202    /// typically in production to prevent schema information leakage.
203    IntrospectionDisabled {
204        /// Description of why introspection is disabled
205        detail: String,
206    },
207
208    /// Query contains too many aliases (alias amplification attack).
209    ///
210    /// Alias amplification is a `DoS` technique where many aliases resolve the same
211    /// expensive field, multiplying backend work with a small query string.
212    TooManyAliases {
213        /// Actual number of aliases in the query
214        alias_count: usize,
215        /// Maximum allowed alias count
216        max_aliases: usize,
217    },
218
219    /// Query is not valid GraphQL syntax.
220    ///
221    /// The query string could not be parsed by the GraphQL parser.
222    MalformedQuery(String),
223}
224
225/// Convenience type alias for security operation results.
226///
227/// Use `Result<T>` in security modules for consistent error handling.
228pub(crate) type Result<T> = std::result::Result<T, SecurityError>;
229
230impl fmt::Display for SecurityError {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::RateLimitExceeded {
234                retry_after,
235                limit,
236                window_secs,
237            } => {
238                write!(
239                    f,
240                    "Rate limit exceeded. Limit: {limit} per {window_secs} seconds. Retry after: {retry_after} seconds"
241                )
242            },
243            Self::QueryTooDeep { depth, max_depth } => {
244                write!(f, "Query too deep: {depth} levels (max: {max_depth})")
245            },
246            Self::QueryTooComplex {
247                complexity,
248                max_complexity,
249            } => {
250                write!(f, "Query too complex: {complexity} (max: {max_complexity})")
251            },
252            Self::QueryTooLarge { size, max_size } => {
253                write!(f, "Query too large: {size} bytes (max: {max_size})")
254            },
255            Self::OriginNotAllowed(origin) => {
256                write!(f, "CORS origin not allowed: {origin}")
257            },
258            Self::MethodNotAllowed(method) => {
259                write!(f, "CORS method not allowed: {method}")
260            },
261            Self::HeaderNotAllowed(header) => {
262                write!(f, "CORS header not allowed: {header}")
263            },
264            Self::InvalidCSRFToken(reason) => {
265                write!(f, "Invalid CSRF token: {reason}")
266            },
267            Self::CSRFSessionMismatch => {
268                write!(f, "CSRF token session mismatch")
269            },
270            Self::AuditLogFailure(reason) => {
271                write!(f, "Audit logging failed: {reason}")
272            },
273            Self::SecurityConfigError(reason) => {
274                write!(f, "Security configuration error: {reason}")
275            },
276            Self::TlsRequired { detail } => {
277                write!(f, "TLS/HTTPS required: {detail}")
278            },
279            Self::TlsVersionTooOld { current, required } => {
280                write!(f, "TLS version too old: {current} (required: {required})")
281            },
282            Self::MtlsRequired { detail } => {
283                write!(f, "Mutual TLS required: {detail}")
284            },
285            Self::InvalidClientCert { detail } => {
286                write!(f, "Invalid client certificate: {detail}")
287            },
288            Self::AuthRequired => {
289                write!(f, "Authentication required")
290            },
291            Self::InvalidToken => {
292                write!(f, "Invalid authentication token")
293            },
294            Self::JwtSignatureInvalid => {
295                write!(
296                    f,
297                    "JWT signature invalid — verify FRAISEQL_JWT_SECRET matches the secret \
298                     used to sign the token"
299                )
300            },
301            Self::JwtIssuerMismatch { expected } => {
302                write!(
303                    f,
304                    "JWT issuer does not match expected '{expected}' — \
305                     check [security.jwt] issuer in fraiseql.toml"
306                )
307            },
308            Self::JwtAudienceMismatch { expected } => {
309                write!(
310                    f,
311                    "JWT audience does not match expected '{expected}' — \
312                     check [security.jwt] audience in fraiseql.toml"
313                )
314            },
315            Self::TokenExpired { expired_at } => {
316                write!(f, "Token expired at {expired_at}")
317            },
318            Self::TokenMissingClaim { claim } => {
319                write!(f, "Token missing required claim: {claim}")
320            },
321            Self::InvalidTokenAlgorithm { algorithm } => {
322                write!(f, "Invalid token algorithm: {algorithm}")
323            },
324            Self::TokenReplayed => {
325                write!(f, "Token has already been used (replay detected)")
326            },
327            Self::IntrospectionDisabled { detail } => {
328                write!(f, "Introspection disabled: {detail}")
329            },
330            Self::TooManyAliases {
331                alias_count,
332                max_aliases,
333            } => {
334                write!(f, "Query contains too many aliases: {alias_count} > {max_aliases}")
335            },
336            Self::MalformedQuery(msg) => {
337                write!(f, "Malformed GraphQL query: {msg}")
338            },
339        }
340    }
341}
342
343impl std::error::Error for SecurityError {
344    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
345        // All SecurityError variants carry textual descriptions only;
346        // none wrap a boxed source error, so the causal chain terminates here.
347        // If a variant is added that wraps a cause, add a match arm returning it.
348        None
349    }
350}
351
352impl PartialEq for SecurityError {
353    #[allow(clippy::match_same_arms)] // Reason: each arm binds distinct enum variant fields; combining would lose per-variant clarity
354    fn eq(&self, other: &Self) -> bool {
355        match (self, other) {
356            (
357                Self::RateLimitExceeded {
358                    retry_after: r1,
359                    limit: l1,
360                    window_secs: w1,
361                },
362                Self::RateLimitExceeded {
363                    retry_after: r2,
364                    limit: l2,
365                    window_secs: w2,
366                },
367            ) => r1 == r2 && l1 == l2 && w1 == w2,
368            (
369                Self::QueryTooDeep {
370                    depth: d1,
371                    max_depth: m1,
372                },
373                Self::QueryTooDeep {
374                    depth: d2,
375                    max_depth: m2,
376                },
377            ) => d1 == d2 && m1 == m2,
378            (
379                Self::QueryTooComplex {
380                    complexity: c1,
381                    max_complexity: m1,
382                },
383                Self::QueryTooComplex {
384                    complexity: c2,
385                    max_complexity: m2,
386                },
387            ) => c1 == c2 && m1 == m2,
388            (
389                Self::QueryTooLarge {
390                    size: s1,
391                    max_size: m1,
392                },
393                Self::QueryTooLarge {
394                    size: s2,
395                    max_size: m2,
396                },
397            ) => s1 == s2 && m1 == m2,
398            (Self::OriginNotAllowed(o1), Self::OriginNotAllowed(o2)) => o1 == o2,
399            (Self::MethodNotAllowed(m1), Self::MethodNotAllowed(m2)) => m1 == m2,
400            (Self::HeaderNotAllowed(h1), Self::HeaderNotAllowed(h2)) => h1 == h2,
401            (Self::InvalidCSRFToken(r1), Self::InvalidCSRFToken(r2)) => r1 == r2,
402            (Self::CSRFSessionMismatch, Self::CSRFSessionMismatch) => true,
403            (Self::AuditLogFailure(r1), Self::AuditLogFailure(r2)) => r1 == r2,
404            (Self::SecurityConfigError(r1), Self::SecurityConfigError(r2)) => r1 == r2,
405            (Self::TlsRequired { detail: d1 }, Self::TlsRequired { detail: d2 }) => d1 == d2,
406            (
407                Self::TlsVersionTooOld {
408                    current: c1,
409                    required: r1,
410                },
411                Self::TlsVersionTooOld {
412                    current: c2,
413                    required: r2,
414                },
415            ) => c1 == c2 && r1 == r2,
416            (Self::MtlsRequired { detail: d1 }, Self::MtlsRequired { detail: d2 }) => d1 == d2,
417            (Self::InvalidClientCert { detail: d1 }, Self::InvalidClientCert { detail: d2 }) => {
418                d1 == d2
419            },
420            (Self::AuthRequired, Self::AuthRequired) => true,
421            (Self::InvalidToken, Self::InvalidToken) => true,
422            (Self::JwtSignatureInvalid, Self::JwtSignatureInvalid) => true,
423            (
424                Self::JwtIssuerMismatch { expected: e1 },
425                Self::JwtIssuerMismatch { expected: e2 },
426            ) => e1 == e2,
427            (
428                Self::JwtAudienceMismatch { expected: e1 },
429                Self::JwtAudienceMismatch { expected: e2 },
430            ) => e1 == e2,
431            (Self::TokenExpired { expired_at: e1 }, Self::TokenExpired { expired_at: e2 }) => {
432                e1 == e2
433            },
434            (Self::TokenMissingClaim { claim: c1 }, Self::TokenMissingClaim { claim: c2 }) => {
435                c1 == c2
436            },
437            (
438                Self::InvalidTokenAlgorithm { algorithm: a1 },
439                Self::InvalidTokenAlgorithm { algorithm: a2 },
440            ) => a1 == a2,
441            (Self::TokenReplayed, Self::TokenReplayed) => true,
442            (
443                Self::IntrospectionDisabled { detail: d1 },
444                Self::IntrospectionDisabled { detail: d2 },
445            ) => d1 == d2,
446            (
447                Self::TooManyAliases {
448                    alias_count: a1,
449                    max_aliases: m1,
450                },
451                Self::TooManyAliases {
452                    alias_count: a2,
453                    max_aliases: m2,
454                },
455            ) => a1 == a2 && m1 == m2,
456            (Self::MalformedQuery(m1), Self::MalformedQuery(m2)) => m1 == m2,
457            _ => false,
458        }
459    }
460}
461
462impl Eq for SecurityError {}