fraiseql-core 2.18.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Security-specific error types for comprehensive error handling.
//!
//! This module defines all security-related error types used throughout
//! the framework. No `PyO3` decorators - all types are pure Rust.
//!
//! Note: The `PyO3` FFI wrappers for Python are in `py/src/ffi/errors.rs`

use std::fmt;

/// Main security error type for all security operations.
///
/// Covers rate limiting, query validation, CORS, CSRF, audit logging,
/// and security configuration errors.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SecurityError {
    /// Rate limiting exceeded - client has made too many requests.
    ///
    /// Contains:
    /// - `retry_after`: Seconds to wait before retrying
    /// - `limit`: Maximum allowed requests
    /// - `window_secs`: Time window in seconds
    RateLimitExceeded {
        /// Seconds to wait before retrying
        retry_after: u64,
        /// Maximum allowed requests
        limit:       usize,
        /// Time window in seconds
        window_secs: u64,
    },

    /// Query validation: depth exceeds maximum allowed.
    ///
    /// GraphQL queries can nest arbitrarily deep, which can cause
    /// excessive database queries or resource consumption.
    QueryTooDeep {
        /// Actual query depth
        depth:     usize,
        /// Maximum allowed depth
        max_depth: usize,
    },

    /// Query validation: complexity exceeds configured limit.
    ///
    /// Complexity is calculated as a weighted sum of field costs,
    /// accounting for pagination and nested selections.
    QueryTooComplex {
        /// Actual query complexity score
        complexity:     usize,
        /// Maximum allowed complexity
        max_complexity: usize,
    },

    /// Query validation: size exceeds maximum allowed bytes.
    ///
    /// Very large queries can consume memory or cause `DoS`.
    QueryTooLarge {
        /// Actual query size in bytes
        size:     usize,
        /// Maximum allowed size in bytes
        max_size: usize,
    },

    /// CORS origin not in allowed list.
    OriginNotAllowed(String),

    /// CORS HTTP method not allowed.
    MethodNotAllowed(String),

    /// CORS header not in allowed list.
    HeaderNotAllowed(String),

    /// CSRF token validation failed.
    InvalidCSRFToken(String),

    /// CSRF token session ID mismatch.
    CSRFSessionMismatch,

    /// Audit log write failure.
    ///
    /// Audit logging to the database failed. The underlying
    /// reason is captured in the error string.
    AuditLogFailure(String),

    /// Security configuration error.
    ///
    /// The security configuration is invalid or incomplete.
    SecurityConfigError(String),

    /// Authentication is required but none was provided.
    ///
    /// Used in auth middleware when authentication is required
    /// (configured or policy enforces it) but no valid credentials
    /// were found in the request.
    AuthRequired,

    /// Authentication token is invalid or malformed.
    ///
    /// The provided authentication token (e.g., JWT) failed to parse
    /// or validate. Could be due to invalid signature, bad format, etc.
    InvalidToken,

    /// JWT signature verification failed.
    ///
    /// The token's signature does not match the configured signing secret.
    /// Most commonly caused by a wrong or rotated `FRAISEQL_JWT_SECRET`.
    JwtSignatureInvalid,

    /// JWT issuer claim does not match the expected issuer.
    ///
    /// The token was issued by a different service than expected.
    /// Check `[security.jwt] issuer` in fraiseql.toml.
    JwtIssuerMismatch {
        /// The expected issuer from configuration
        expected: String,
    },

    /// JWT audience claim does not match the expected audience.
    ///
    /// The token was issued for a different service or audience.
    /// Check `[security.jwt] audience` in fraiseql.toml.
    JwtAudienceMismatch {
        /// The expected audience from configuration
        expected: String,
    },

    /// Authentication token has expired.
    ///
    /// The authentication token has an 'exp' claim and that timestamp
    /// has passed. The user needs to re-authenticate.
    TokenExpired {
        /// The time when the token expired
        expired_at: chrono::DateTime<chrono::Utc>,
    },

    /// Authentication token is missing a required claim.
    ///
    /// The authentication token doesn't have a required claim like 'sub', 'exp', etc.
    TokenMissingClaim {
        /// The name of the claim that's missing
        claim: String,
    },

    /// Authentication token algorithm doesn't match expected algorithm.
    ///
    /// The token was signed with a different algorithm than expected
    /// (e.g., token used HS256 but system expects RS256).
    InvalidTokenAlgorithm {
        /// The algorithm used in the token
        algorithm: String,
    },

    /// JWT token has been replayed — its `jti` (JWT ID) was already used.
    ///
    /// A previously-validated token is being reused. This indicates a
    /// stolen-token replay attack. The token should be rejected and the event
    /// should trigger a security alert.
    TokenReplayed,

    /// GraphQL introspection query is not allowed.
    ///
    /// The security policy disallows introspection queries (__schema, __type),
    /// typically in production to prevent schema information leakage.
    IntrospectionDisabled {
        /// Description of why introspection is disabled
        detail: String,
    },

    /// Query contains too many aliases (alias amplification attack).
    ///
    /// Alias amplification is a `DoS` technique where many aliases resolve the same
    /// expensive field, multiplying backend work with a small query string.
    TooManyAliases {
        /// Actual number of aliases in the query
        alias_count: usize,
        /// Maximum allowed alias count
        max_aliases: usize,
    },

    /// Query is not valid GraphQL syntax.
    ///
    /// The query string could not be parsed by the GraphQL parser.
    MalformedQuery(String),
}

/// Convenience type alias for security operation results.
///
/// Use `Result<T>` in security modules for consistent error handling.
pub(crate) type Result<T> = std::result::Result<T, SecurityError>;

impl fmt::Display for SecurityError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::RateLimitExceeded {
                retry_after,
                limit,
                window_secs,
            } => {
                write!(
                    f,
                    "Rate limit exceeded. Limit: {limit} per {window_secs} seconds. Retry after: {retry_after} seconds"
                )
            },
            Self::QueryTooDeep { depth, max_depth } => {
                write!(f, "Query too deep: {depth} levels (max: {max_depth})")
            },
            Self::QueryTooComplex {
                complexity,
                max_complexity,
            } => {
                write!(f, "Query too complex: {complexity} (max: {max_complexity})")
            },
            Self::QueryTooLarge { size, max_size } => {
                write!(f, "Query too large: {size} bytes (max: {max_size})")
            },
            Self::OriginNotAllowed(origin) => {
                write!(f, "CORS origin not allowed: {origin}")
            },
            Self::MethodNotAllowed(method) => {
                write!(f, "CORS method not allowed: {method}")
            },
            Self::HeaderNotAllowed(header) => {
                write!(f, "CORS header not allowed: {header}")
            },
            Self::InvalidCSRFToken(reason) => {
                write!(f, "Invalid CSRF token: {reason}")
            },
            Self::CSRFSessionMismatch => {
                write!(f, "CSRF token session mismatch")
            },
            Self::AuditLogFailure(reason) => {
                write!(f, "Audit logging failed: {reason}")
            },
            Self::SecurityConfigError(reason) => {
                write!(f, "Security configuration error: {reason}")
            },
            Self::AuthRequired => {
                write!(f, "Authentication required")
            },
            Self::InvalidToken => {
                write!(f, "Invalid authentication token")
            },
            Self::JwtSignatureInvalid => {
                write!(
                    f,
                    "JWT signature invalid — verify FRAISEQL_JWT_SECRET matches the secret \
                     used to sign the token"
                )
            },
            Self::JwtIssuerMismatch { expected } => {
                write!(
                    f,
                    "JWT issuer does not match expected '{expected}' — \
                     check [security.jwt] issuer in fraiseql.toml"
                )
            },
            Self::JwtAudienceMismatch { expected } => {
                write!(
                    f,
                    "JWT audience does not match expected '{expected}' — \
                     check [security.jwt] audience in fraiseql.toml"
                )
            },
            Self::TokenExpired { expired_at } => {
                write!(f, "Token expired at {expired_at}")
            },
            Self::TokenMissingClaim { claim } => {
                write!(f, "Token missing required claim: {claim}")
            },
            Self::InvalidTokenAlgorithm { algorithm } => {
                write!(f, "Invalid token algorithm: {algorithm}")
            },
            Self::TokenReplayed => {
                write!(f, "Token has already been used (replay detected)")
            },
            Self::IntrospectionDisabled { detail } => {
                write!(f, "Introspection disabled: {detail}")
            },
            Self::TooManyAliases {
                alias_count,
                max_aliases,
            } => {
                write!(f, "Query contains too many aliases: {alias_count} > {max_aliases}")
            },
            Self::MalformedQuery(msg) => {
                write!(f, "Malformed GraphQL query: {msg}")
            },
        }
    }
}

impl std::error::Error for SecurityError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        // All SecurityError variants carry textual descriptions only;
        // none wrap a boxed source error, so the causal chain terminates here.
        // If a variant is added that wraps a cause, add a match arm returning it.
        None
    }
}

impl PartialEq for SecurityError {
    #[allow(clippy::match_same_arms)] // Reason: each arm binds distinct enum variant fields; combining would lose per-variant clarity
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (
                Self::RateLimitExceeded {
                    retry_after: r1,
                    limit: l1,
                    window_secs: w1,
                },
                Self::RateLimitExceeded {
                    retry_after: r2,
                    limit: l2,
                    window_secs: w2,
                },
            ) => r1 == r2 && l1 == l2 && w1 == w2,
            (
                Self::QueryTooDeep {
                    depth: d1,
                    max_depth: m1,
                },
                Self::QueryTooDeep {
                    depth: d2,
                    max_depth: m2,
                },
            ) => d1 == d2 && m1 == m2,
            (
                Self::QueryTooComplex {
                    complexity: c1,
                    max_complexity: m1,
                },
                Self::QueryTooComplex {
                    complexity: c2,
                    max_complexity: m2,
                },
            ) => c1 == c2 && m1 == m2,
            (
                Self::QueryTooLarge {
                    size: s1,
                    max_size: m1,
                },
                Self::QueryTooLarge {
                    size: s2,
                    max_size: m2,
                },
            ) => s1 == s2 && m1 == m2,
            (Self::OriginNotAllowed(o1), Self::OriginNotAllowed(o2)) => o1 == o2,
            (Self::MethodNotAllowed(m1), Self::MethodNotAllowed(m2)) => m1 == m2,
            (Self::HeaderNotAllowed(h1), Self::HeaderNotAllowed(h2)) => h1 == h2,
            (Self::InvalidCSRFToken(r1), Self::InvalidCSRFToken(r2)) => r1 == r2,
            (Self::CSRFSessionMismatch, Self::CSRFSessionMismatch) => true,
            (Self::AuditLogFailure(r1), Self::AuditLogFailure(r2)) => r1 == r2,
            (Self::SecurityConfigError(r1), Self::SecurityConfigError(r2)) => r1 == r2,
            (Self::AuthRequired, Self::AuthRequired) => true,
            (Self::InvalidToken, Self::InvalidToken) => true,
            (Self::JwtSignatureInvalid, Self::JwtSignatureInvalid) => true,
            (
                Self::JwtIssuerMismatch { expected: e1 },
                Self::JwtIssuerMismatch { expected: e2 },
            ) => e1 == e2,
            (
                Self::JwtAudienceMismatch { expected: e1 },
                Self::JwtAudienceMismatch { expected: e2 },
            ) => e1 == e2,
            (Self::TokenExpired { expired_at: e1 }, Self::TokenExpired { expired_at: e2 }) => {
                e1 == e2
            },
            (Self::TokenMissingClaim { claim: c1 }, Self::TokenMissingClaim { claim: c2 }) => {
                c1 == c2
            },
            (
                Self::InvalidTokenAlgorithm { algorithm: a1 },
                Self::InvalidTokenAlgorithm { algorithm: a2 },
            ) => a1 == a2,
            (Self::TokenReplayed, Self::TokenReplayed) => true,
            (
                Self::IntrospectionDisabled { detail: d1 },
                Self::IntrospectionDisabled { detail: d2 },
            ) => d1 == d2,
            (
                Self::TooManyAliases {
                    alias_count: a1,
                    max_aliases: m1,
                },
                Self::TooManyAliases {
                    alias_count: a2,
                    max_aliases: m2,
                },
            ) => a1 == a2 && m1 == m2,
            (Self::MalformedQuery(m1), Self::MalformedQuery(m2)) => m1 == m2,
            _ => false,
        }
    }
}

impl Eq for SecurityError {}