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 {}