fraiseql-server 2.16.0

HTTP server for FraiseQL v2 GraphQL engine
//! Per-tenant executor dispatch, shared by every transport that executes GraphQL.
//!
//! Resolving which tenant a request belongs to, refusing an unregistered key,
//! refusing a suspended tenant and charging the tenant's quotas is one policy. It
//! used to be written out inline in the `/graphql` handler and nowhere else, so
//! the MCP transport — mounted from the same [`AppState`] — captured the default
//! executor at session construction and never consulted the registry at all: an
//! authenticated MCP caller read the boot database rather than their own tenant's,
//! and a suspended tenant kept working over MCP while `/graphql` correctly
//! answered 503 (#858).
//!
//! Both steps live here so a control added to one transport is a control on both.
//! They are two functions rather than one because the caller distinguishes their
//! failures: a malformed `X-Tenant-ID` is the client's mistake (400) while an
//! unregistered or suspended tenant is a dispatch decision (403 / 503 / 429).

use std::sync::Arc;

use axum::http::HeaderMap;
use fraiseql_core::{runtime::Executor, security::SecurityContext};

use super::{AppState, TenantKeyResolver};

/// The executor a request must run on, plus the quota permits it holds.
pub struct TenantDispatch {
    /// The tenant's executor, or the default one when no key was resolved.
    pub executor: arc_swap::Guard<Arc<Executor>>,

    /// The per-tenant concurrency permit, released when this value is dropped.
    ///
    /// Bound for the remainder of the request: dropping it early would let a
    /// tenant exceed its configured in-flight quota.
    _concurrency_permit: Option<tokio::sync::OwnedSemaphorePermit>,
}

/// Resolve the tenant key for a request from its security context and headers.
///
/// Strict cross-source validation is enabled exactly when the compiled schema
/// configures RLS, so a multi-tenant deployment cannot be addressed with
/// conflicting tenant hints.
///
/// # Errors
///
/// Returns `FraiseQLError::Validation` when the `X-Tenant-ID` header is malformed,
/// or when strict validation is on and the available sources disagree.
pub fn resolve_tenant_key(
    state: &AppState,
    security_context: Option<&SecurityContext>,
    headers: &HeaderMap,
) -> fraiseql_error::Result<Option<String>> {
    let strict = state.executor().schema().has_rls_configured();
    TenantKeyResolver::resolve(security_context, headers, Some(state.domain_registry()), strict)
}

/// Dispatch to the resolved tenant's executor and charge its quotas.
///
/// - `None` key → the default executor, unlimited.
/// - registered + active → the tenant's own executor, holding a concurrency permit and having
///   consumed one request from the per-second window.
/// - registered + suspended → `ServiceUnavailable`.
/// - not registered → `Authorization`. Never a silent fallback to the default executor, which would
///   serve another tenant's data.
///
/// # Errors
///
/// Propagates the registry's decision: `Authorization` for an unregistered key,
/// `ServiceUnavailable` for a suspended tenant, `RateLimited` when a quota is
/// exhausted.
pub fn dispatch_to_tenant(
    state: &AppState,
    tenant_key: Option<&str>,
) -> fraiseql_error::Result<TenantDispatch> {
    let executor = state.executor_for_tenant(tenant_key)?;

    // M-quotas: enforce the per-tenant concurrency limit. Only an explicit,
    // registered tenant key carries a limit — the default (`None`) executor is
    // unlimited, and the registry errors on an unregistered key. The acquired
    // permit is bound for the remainder of the request and released on drop, so a
    // tenant can never exceed its configured in-flight quota. An exhausted limit
    // surfaces as `RateLimited` → HTTP 429.
    let concurrency_permit = match (tenant_key, state.tenant_registry()) {
        (Some(key), Some(registry)) => registry.try_acquire_concurrency(key)?,
        _ => None,
    };

    // M-quotas (RPS): enforce the per-tenant per-second request-rate limit at the
    // same chokepoint. Like the concurrency permit, this is meaningful only for an
    // explicitly-keyed, registered tenant.
    #[cfg(feature = "auth")]
    if let (Some(key), Some(registry)) = (tenant_key, state.tenant_registry()) {
        registry.try_acquire_rps(key)?;
    }

    Ok(TenantDispatch {
        executor,
        _concurrency_permit: concurrency_permit,
    })
}

/// Estimate the cost of `document` for budget enforcement and observability
/// (#379).
///
/// Returns `None` for a document that does not parse — rejection is left for
/// the executor's own parse-error path. Computed unconditionally per request
/// (one extra parse of an already-size-limited string) so an operator can
/// observe real traffic costs *before* configuring any budget: the number that
/// sizes `[security.cost_budget]` has to exist prior to enforcement.
#[must_use]
pub fn estimate_request_cost(
    document: &str,
    variables: Option<&serde_json::Value>,
    executor: &Executor,
) -> Option<u64> {
    let doc = fraiseql_core::graphql::parse_graphql_document(document).ok()?;
    Some(fraiseql_core::graphql::estimate_query_cost(
        &doc,
        &executor.schema().operation_cost_weights,
        variables,
    ) as u64)
}

/// Charge the tenant's cost budgets with a precomputed `cost` (#379).
///
/// Only an explicitly-keyed, registered tenant carries budgets. `cost` is
/// `None` for an unparseable document, which is left for the executor to
/// reject.
///
/// Lives beside the other two because it is the fourth per-tenant quota and
/// belongs to the same chokepoint; it is separate only because it needs the
/// document's cost.
///
/// **Deliberately not called by the MCP transport.** `estimate_query_cost` scores
/// the *shape* of the document — root fields against
/// `operation_cost_weights`, and the selection set — none of which an MCP caller
/// can vary: the document is built from the schema, carries exactly one root
/// field and a fixed scalar projection, and argument values travel as variables
/// (#808). The score for a given tool is therefore constant, so the check would
/// either always pass or permanently disable that tool for a budgeted tenant,
/// rather than metering anything. Volume over MCP is bounded by the concurrency
/// permit and the per-second limiter in [`dispatch_to_tenant`], which do apply.
/// (The schema-wide `[security.cost_budget] per_request_max` is different: it
/// is the operator capping their own schema, enforced inside the executor for
/// every transport including MCP.) If a future MCP surface lets the caller
/// shape the document, this is the call to add.
///
/// # Errors
///
/// Returns `FraiseQLError::CostExceeded` when `cost` exceeds the tenant's
/// per-request budget (no retry hint) or exhausts its per-minute window
/// (`Retry-After` hint).
pub fn charge_cost_budget(
    state: &AppState,
    tenant_key: Option<&str>,
    security_context: Option<&fraiseql_core::security::SecurityContext>,
    cost: Option<u64>,
) -> fraiseql_error::Result<()> {
    let (Some(key), Some(registry), Some(cost)) = (tenant_key, state.tenant_registry(), cost)
    else {
        return Ok(());
    };
    if !registry.has_cost_budget(key) {
        return Ok(());
    }
    // The actor class the budget is keyed on (#966). `None` for an
    // unauthenticated request, which then takes the tenant-wide budget — the
    // only safe reading, since `ActorType::default()` is `HumanUser` and
    // defaulting would hand anonymous traffic whatever allowance the humans got.
    let actor = security_context.map(fraiseql_core::security::SecurityContext::actor_type);
    #[allow(clippy::cast_possible_truncation)]
    // Reason: cost values are bounded by document size; u64→usize is lossless on 64-bit
    let cost = cost as usize;
    // Per-request ceiling first — an operation that can never run must not
    // consume any of the rolling window (#379).
    registry.check_cost_budget(key, actor, cost)?;
    registry.charge_cost_window(key, actor, cost)?;
    Ok(())
}