fraiseql-server 2.16.0

HTTP server for FraiseQL v2 GraphQL engine
//! Application router construction and route registration.
//!
//! Split into sub-modules by responsibility:
//! - [`state`]: `AppState` construction
//! - [`graphql`]: GraphQL endpoint with auth and compression
//! - [`admin`]: Base routes, studio, admin API, introspection, metrics, design audit
//! - [`auth`]: PKCE, social login, MFA, session identity, token revocation
//! - [`extensions`]: MCP, API routes, RBAC, observers, storage, functions, REST
//! - [`middleware`]: Tracing, CORS, body/header limits, timeout, rate limiting
//! - [`observers`]: Observer management routes

mod admin;
#[cfg(feature = "auth")]
mod auth;
mod extensions;
mod graphql;
#[cfg(test)]
mod http_query_method_tests;
mod middleware;
#[cfg(test)]
mod mount_authz_tests;
#[cfg(feature = "observers")]
pub(in crate::server) mod observers;
#[cfg(test)]
mod persisted_only_transport_tests;
#[cfg(test)]
mod realtime_removal_survival_tests;
mod state;
#[cfg(test)]
mod storage_policy_admin_tests;

use std::sync::Arc;

use axum::{Router, middleware::from_fn_with_state};
use fraiseql_core::security::OidcValidator;
use tracing::info;

use super::{OidcAuthState, Server, oidc_auth_middleware};
use crate::{
    middleware::{Hs256AuthState, TenantClaim, hs256_auth_middleware},
    routes::graphql::AppState,
};

/// Whether a data-serving transport authenticates its callers.
///
/// Every transport that serves schema-backed data must declare one of these when it is
/// mounted. The variant is not a preference — it is the answer to "who may reach the
/// handlers on this router", and #812 shipped because that question was simply never
/// asked for REST: the router was merged with no auth layer at all, so
/// `security_context` was `None` on every request and the runtime's RLS and
/// session-variable tenant stamping were skipped in silence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum AuthPosture {
    /// Callers are authenticated by whichever validator the deployment configured
    /// (OIDC or HS256), exactly as `/graphql` authenticates them. When no validator is
    /// configured this is a no-op layer and the transport serves anonymous callers —
    /// which is why every guard downstream must still fail closed on a `None` context
    /// rather than treat it as "no filter required".
    Authenticated,
}

impl Server {
    /// Attach the deployment's configured authentication layer to `router`.
    ///
    /// This is the single place any data-serving transport acquires authentication.
    /// `/graphql` and the REST transport both route through it so the two cannot drift:
    /// before #812 they had independent mount code and REST's simply omitted the layer.
    ///
    /// axum's `route_layer` applies only to routes already registered on the *same*
    /// `Router` — `Router::merge` does **not** propagate it — so this must be called on
    /// the transport's own router before it is merged into the application.
    pub(super) fn attach_auth<S>(
        &self,
        router: Router<S>,
        posture: AuthPosture,
        transport: &str,
    ) -> Router<S>
    where
        S: Clone + Send + Sync + 'static,
    {
        let AuthPosture::Authenticated = posture;

        if let Some(ref validator) = self.oidc_validator {
            info!(transport, "transport protected by OIDC authentication");
            let auth_state = self.oidc_auth_state(Arc::clone(validator));
            return router.route_layer(from_fn_with_state(auth_state, oidc_auth_middleware));
        }

        if let Some(ref validator) = self.hs256_auth {
            info!(transport, "transport protected by HS256 authentication");
            let realm = self
                .config
                .auth_hs256
                .as_ref()
                .and_then(|h| h.issuer.clone())
                .unwrap_or_else(|| "fraiseql".to_string());
            // #934: without the authenticator, this layer refuses a bearer-less
            // service-account request before the handler's ADR-0018 seam runs.
            let auth_state = self
                .hs256_auth_state(Arc::clone(validator), realm)
                .with_service_accounts(self.service_account_authenticator.clone());
            return router.route_layer(from_fn_with_state(auth_state, hs256_auth_middleware));
        }

        info!(
            transport,
            "no authentication configured — transport serves anonymous callers; row-scoping \
             guards must fail closed on an absent security context"
        );
        router
    }
}

impl Server {
    /// Build an [`OidcAuthState`] for `validator`, attaching the configured
    /// token-revocation manager (if any) so revoked tokens are rejected on **every**
    /// authenticated route (H8).
    ///
    /// All OIDC middleware construction goes through this helper to keep revocation
    /// enforcement uniform: a bare `OidcAuthState::new` at a route would silently skip
    /// the revocation check for that route.
    pub(super) fn oidc_auth_state(&self, validator: Arc<OidcValidator>) -> OidcAuthState {
        OidcAuthState::new(validator, TenantClaim::of(self.executor.schema()))
            .with_revocation(self.revocation_manager.clone())
    }

    /// Build an [`Hs256AuthState`], attaching the configured token-revocation manager —
    /// the HS256 twin of [`oidc_auth_state`](Self::oidc_auth_state).
    ///
    /// `[security.token_revocation]` is a compiled-schema setting independent of the auth
    /// mode, but only the OIDC layer consulted it, so under `[auth_hs256]` a configured
    /// store was inert: Studio's `POST /admin/v1/users/{id}/revoke` recorded the epoch,
    /// answered `"All sessions revoked"`, and every one of that user's tokens kept working
    /// (#1112). `[auth.social]` and `[auth.local]` both require `[auth_hs256]`, so that was
    /// the auth mode of every social-login and local-password deployment.
    ///
    /// This helper exists for the same reason its OIDC twin does: a bare
    /// `Hs256AuthState::new` at a mount would silently skip revocation for that mount.
    pub(super) fn hs256_auth_state(
        &self,
        validator: Arc<fraiseql_core::security::AuthMiddleware>,
        realm: String,
    ) -> Hs256AuthState {
        Hs256AuthState::new(validator, realm, TenantClaim::of(self.executor.schema()))
            .with_revocation(self.revocation_manager.clone())
    }

    /// Build the application router over the shared `AppState`.
    ///
    /// Takes the state rather than building it, so the router cannot be mounted over
    /// a state that skipped provisioning (see `Server::provisioned_app_state`).
    pub(super) fn build_router(&self, state: &AppState) -> Router {
        // Build GraphQL route (possibly with auth + Content-Type enforcement).
        let graphql_router = self.build_graphql_router(state);

        // Mount base routes, studio, admin, introspection, metrics, design audit.
        let mut app = Router::new();
        app = self.mount_base_and_admin_routes(app.merge(graphql_router), state);

        // Mount auth routes (PKCE, social, MFA, /auth/me, revocation).
        #[cfg(feature = "auth")]
        {
            app = self.mount_auth_routes(app);
        }

        // Mount extension routes (MCP, API, RBAC, storage, functions, REST).
        app = self.mount_extensions(app, state);

        // Apply global middleware layers (metrics, tracing, CORS, limits, timeout, rate limiting).
        app = self.apply_middleware(app, state);

        app
    }
}