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 {}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_rate_limit_error_display() {
470        let err = SecurityError::RateLimitExceeded {
471            retry_after: 60,
472            limit:       100,
473            window_secs: 60,
474        };
475
476        assert!(err.to_string().contains("Rate limit exceeded"));
477        assert!(err.to_string().contains("100"));
478        assert!(err.to_string().contains("60"));
479    }
480
481    #[test]
482    fn test_query_too_deep_display() {
483        let err = SecurityError::QueryTooDeep {
484            depth:     20,
485            max_depth: 10,
486        };
487
488        assert!(err.to_string().contains("Query too deep"));
489        assert!(err.to_string().contains("20"));
490        assert!(err.to_string().contains("10"));
491    }
492
493    #[test]
494    fn test_query_too_complex_display() {
495        let err = SecurityError::QueryTooComplex {
496            complexity:     500,
497            max_complexity: 100,
498        };
499
500        assert!(err.to_string().contains("Query too complex"));
501        assert!(err.to_string().contains("500"));
502        assert!(err.to_string().contains("100"));
503    }
504
505    #[test]
506    fn test_query_too_large_display() {
507        let err = SecurityError::QueryTooLarge {
508            size:     100_000,
509            max_size: 10_000,
510        };
511
512        assert!(err.to_string().contains("Query too large"));
513        assert!(err.to_string().contains("100000"));
514        assert!(err.to_string().contains("10000"));
515    }
516
517    #[test]
518    fn test_cors_errors() {
519        let origin_err = SecurityError::OriginNotAllowed("https://evil.com".to_string());
520        assert!(origin_err.to_string().contains("CORS origin"));
521
522        let method_err = SecurityError::MethodNotAllowed("DELETE".to_string());
523        assert!(method_err.to_string().contains("CORS method"));
524
525        let header_err = SecurityError::HeaderNotAllowed("X-Custom".to_string());
526        assert!(header_err.to_string().contains("CORS header"));
527    }
528
529    #[test]
530    fn test_csrf_errors() {
531        let invalid = SecurityError::InvalidCSRFToken("expired".to_string());
532        assert!(invalid.to_string().contains("Invalid CSRF token"));
533
534        let mismatch = SecurityError::CSRFSessionMismatch;
535        assert!(mismatch.to_string().contains("session mismatch"));
536    }
537
538    #[test]
539    fn test_audit_error() {
540        let err = SecurityError::AuditLogFailure("connection timeout".to_string());
541        assert!(err.to_string().contains("Audit logging failed"));
542    }
543
544    #[test]
545    fn test_config_error() {
546        let err = SecurityError::SecurityConfigError("missing config key".to_string());
547        assert!(err.to_string().contains("Security configuration error"));
548    }
549
550    #[test]
551    fn test_error_equality() {
552        let err1 = SecurityError::QueryTooDeep {
553            depth:     20,
554            max_depth: 10,
555        };
556        let err2 = SecurityError::QueryTooDeep {
557            depth:     20,
558            max_depth: 10,
559        };
560        assert_eq!(err1, err2);
561
562        let err3 = SecurityError::QueryTooDeep {
563            depth:     30,
564            max_depth: 10,
565        };
566        assert_ne!(err1, err3);
567    }
568
569    #[test]
570    fn test_rate_limit_equality() {
571        let err1 = SecurityError::RateLimitExceeded {
572            retry_after: 60,
573            limit:       100,
574            window_secs: 60,
575        };
576        let err2 = SecurityError::RateLimitExceeded {
577            retry_after: 60,
578            limit:       100,
579            window_secs: 60,
580        };
581        assert_eq!(err1, err2);
582    }
583
584    // ============================================================================
585    // TLS Error Tests
586    // ============================================================================
587
588    #[test]
589    fn test_tls_required_error_display() {
590        let err = SecurityError::TlsRequired {
591            detail: "HTTPS required".to_string(),
592        };
593
594        assert!(err.to_string().contains("TLS/HTTPS required"));
595        assert!(err.to_string().contains("HTTPS required"));
596    }
597
598    #[test]
599    fn test_tls_version_too_old_error_display() {
600        use crate::security::tls_enforcer::TlsVersion;
601
602        let err = SecurityError::TlsVersionTooOld {
603            current:  TlsVersion::V1_2,
604            required: TlsVersion::V1_3,
605        };
606
607        assert!(err.to_string().contains("TLS version too old"));
608        assert!(err.to_string().contains("1.2"));
609        assert!(err.to_string().contains("1.3"));
610    }
611
612    #[test]
613    fn test_mtls_required_error_display() {
614        let err = SecurityError::MtlsRequired {
615            detail: "Client certificate required".to_string(),
616        };
617
618        assert!(err.to_string().contains("Mutual TLS required"));
619        assert!(err.to_string().contains("Client certificate"));
620    }
621
622    #[test]
623    fn test_invalid_client_cert_error_display() {
624        let err = SecurityError::InvalidClientCert {
625            detail: "Certificate validation failed".to_string(),
626        };
627
628        assert!(err.to_string().contains("Invalid client certificate"));
629        assert!(err.to_string().contains("validation failed"));
630    }
631
632    #[test]
633    fn test_auth_required_error_display() {
634        let err = SecurityError::AuthRequired;
635        assert!(err.to_string().contains("Authentication required"));
636    }
637
638    #[test]
639    fn test_invalid_token_error_display() {
640        let err = SecurityError::InvalidToken;
641        assert!(err.to_string().contains("Invalid authentication token"));
642    }
643
644    #[test]
645    fn test_token_expired_error_display() {
646        use chrono::{Duration, Utc};
647
648        let expired_at = Utc::now() - Duration::hours(1);
649        let err = SecurityError::TokenExpired { expired_at };
650
651        assert!(err.to_string().contains("Token expired"));
652    }
653
654    #[test]
655    fn test_token_missing_claim_error_display() {
656        let err = SecurityError::TokenMissingClaim {
657            claim: "sub".to_string(),
658        };
659
660        assert!(err.to_string().contains("Token missing required claim"));
661        assert!(err.to_string().contains("sub"));
662    }
663
664    #[test]
665    fn test_invalid_token_algorithm_error_display() {
666        let err = SecurityError::InvalidTokenAlgorithm {
667            algorithm: "HS256".to_string(),
668        };
669
670        assert!(err.to_string().contains("Invalid token algorithm"));
671        assert!(err.to_string().contains("HS256"));
672    }
673
674    #[test]
675    fn test_introspection_disabled_error_display() {
676        let err = SecurityError::IntrospectionDisabled {
677            detail: "Introspection not allowed in production".to_string(),
678        };
679
680        assert!(err.to_string().contains("Introspection disabled"));
681        assert!(err.to_string().contains("production"));
682    }
683
684    // ============================================================================
685    // TLS Error Equality Tests
686    // ============================================================================
687
688    #[test]
689    fn test_tls_required_equality() {
690        let err1 = SecurityError::TlsRequired {
691            detail: "test".to_string(),
692        };
693        let err2 = SecurityError::TlsRequired {
694            detail: "test".to_string(),
695        };
696        assert_eq!(err1, err2);
697
698        let err3 = SecurityError::TlsRequired {
699            detail: "different".to_string(),
700        };
701        assert_ne!(err1, err3);
702    }
703
704    #[test]
705    fn test_tls_version_too_old_equality() {
706        use crate::security::tls_enforcer::TlsVersion;
707
708        let err1 = SecurityError::TlsVersionTooOld {
709            current:  TlsVersion::V1_2,
710            required: TlsVersion::V1_3,
711        };
712        let err2 = SecurityError::TlsVersionTooOld {
713            current:  TlsVersion::V1_2,
714            required: TlsVersion::V1_3,
715        };
716        assert_eq!(err1, err2);
717
718        let err3 = SecurityError::TlsVersionTooOld {
719            current:  TlsVersion::V1_1,
720            required: TlsVersion::V1_3,
721        };
722        assert_ne!(err1, err3);
723    }
724
725    #[test]
726    fn test_mtls_required_equality() {
727        let err1 = SecurityError::MtlsRequired {
728            detail: "test".to_string(),
729        };
730        let err2 = SecurityError::MtlsRequired {
731            detail: "test".to_string(),
732        };
733        assert_eq!(err1, err2);
734    }
735
736    #[test]
737    fn test_invalid_token_equality() {
738        assert_eq!(SecurityError::InvalidToken, SecurityError::InvalidToken);
739    }
740
741    #[test]
742    fn test_auth_required_equality() {
743        assert_eq!(SecurityError::AuthRequired, SecurityError::AuthRequired);
744    }
745}