Skip to main content

fraiseql_server/
trusted_documents.rs

1//! Trusted documents / query allowlist.
2//!
3//! Trusted documents allow only pre-registered queries to execute. At build time
4//! the frontend generates a manifest keyed by SHA-256 hash. At runtime clients
5//! send `{ "documentId": "sha256:abc..." }` instead of a raw query string.
6//!
7//! Two modes:
8//! - **Strict**: only `documentId` requests allowed; raw queries rejected.
9//! - **Permissive**: `documentId` resolved from manifest; raw queries pass through.
10
11use std::{
12    collections::HashMap,
13    path::Path,
14    sync::{
15        Arc,
16        atomic::{AtomicU64, Ordering},
17    },
18};
19
20/// Maximum byte size accepted for a trusted-documents manifest file.
21///
22/// A manifest with 50 000 pre-registered queries at ~200 bytes each is roughly
23/// 10 `MiB` — already an unusually large deployment.  Capping at 10 `MiB` prevents
24/// accidental or malicious loading of a gigabyte-sized file at server startup.
25pub(crate) const MAX_MANIFEST_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
26
27use dashmap::DashMap;
28use serde::Deserialize;
29
30/// Enforcement mode for trusted documents.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum TrustedDocumentMode {
34    /// Only `documentId` requests allowed; raw query strings rejected.
35    Strict,
36    /// `documentId` requests use the manifest; raw queries fall through.
37    Permissive,
38}
39
40/// Manifest JSON format (compatible with Relay, Apollo Client, Envelop).
41#[derive(Debug, Deserialize)]
42struct Manifest {
43    // Reason: serde deserialization target — `version` is present in the manifest JSON
44    // for forward-compatibility but is not consumed by the current lookup logic.
45    #[allow(dead_code)] // Reason: field kept for API completeness; may be used in future features
46    version: u32,
47    documents: HashMap<String, String>,
48}
49
50/// Trusted document lookup store.
51///
52/// Backed by a [`DashMap`]: lookups on the request hot path take only a
53/// per-shard read lock, never an async lock, and hot-reload (the only writer)
54/// replaces entries in place without blocking concurrent readers on other
55/// shards.
56pub struct TrustedDocumentStore {
57    /// hash → query body (keys stored WITHOUT "sha256:" prefix).
58    documents: Arc<DashMap<String, String>>,
59    mode:      TrustedDocumentMode,
60}
61
62impl TrustedDocumentStore {
63    /// Load from a JSON manifest file at startup.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the file cannot be read or parsed.
68    pub fn from_manifest_file(
69        path: &Path,
70        mode: TrustedDocumentMode,
71    ) -> Result<Self, TrustedDocumentError> {
72        // Reject oversized files before reading into memory.
73        let file_size = std::fs::metadata(path)
74            .map_err(|e| {
75                TrustedDocumentError::ManifestLoad(format!(
76                    "Failed to stat manifest {}: {e}",
77                    path.display()
78                ))
79            })?
80            .len();
81        if file_size > MAX_MANIFEST_BYTES {
82            return Err(TrustedDocumentError::ManifestLoad(format!(
83                "Manifest {} is too large ({file_size} bytes, max {MAX_MANIFEST_BYTES})",
84                path.display()
85            )));
86        }
87
88        let contents = std::fs::read_to_string(path).map_err(|e| {
89            TrustedDocumentError::ManifestLoad(format!(
90                "Failed to read manifest {}: {e}",
91                path.display()
92            ))
93        })?;
94        let manifest: Manifest = serde_json::from_str(&contents).map_err(|e| {
95            TrustedDocumentError::ManifestLoad(format!(
96                "Failed to parse manifest {}: {e}",
97                path.display()
98            ))
99        })?;
100        Ok(Self {
101            documents: Arc::new(normalize_keys(manifest.documents)),
102            mode,
103        })
104    }
105
106    /// Create an in-memory store from a pre-built document map (for testing).
107    #[must_use]
108    pub fn from_documents(documents: HashMap<String, String>, mode: TrustedDocumentMode) -> Self {
109        Self {
110            documents: Arc::new(normalize_keys(documents)),
111            mode,
112        }
113    }
114
115    /// A disabled store that passes all queries through (permissive, empty).
116    #[must_use]
117    pub fn disabled() -> Self {
118        Self {
119            documents: Arc::new(DashMap::new()),
120            mode:      TrustedDocumentMode::Permissive,
121        }
122    }
123
124    /// Returns the enforcement mode.
125    #[must_use]
126    pub const fn mode(&self) -> TrustedDocumentMode {
127        self.mode
128    }
129
130    /// Returns the number of documents in the manifest.
131    #[must_use]
132    pub fn document_count(&self) -> usize {
133        self.documents.len()
134    }
135
136    /// Replace the document map (used by hot-reload).
137    ///
138    /// The swap is per-shard atomic — readers may observe the old or the new
139    /// contents but never a torn entry.  Brief inconsistency across shards
140    /// during a reload is acceptable: a request that resolves a document
141    /// that has just been removed will simply 404 and the client will retry.
142    pub fn replace_documents(&self, documents: HashMap<String, String>) {
143        let new_docs = normalize_keys(documents);
144        self.documents.clear();
145        for entry in new_docs {
146            self.documents.insert(entry.0, entry.1);
147        }
148    }
149
150    /// Resolve a query from `document_id` and/or `raw_query`.
151    ///
152    /// - `document_id` present + found → return stored query body.
153    /// - `document_id` present + NOT found → `DocumentNotFound`.
154    /// - No `document_id` in strict mode → `ForbiddenRawQuery`.
155    /// - No `document_id` in permissive mode → return `raw_query`.
156    ///
157    /// # Errors
158    ///
159    /// Returns `TrustedDocumentError::DocumentNotFound` if a `document_id` is given but not in the
160    /// store. Returns `TrustedDocumentError::ForbiddenRawQuery` if no `document_id` is provided
161    /// in strict mode, or if `raw_query` is also absent in permissive mode.
162    pub fn resolve(
163        &self,
164        document_id: Option<&str>,
165        raw_query: Option<&str>,
166    ) -> Result<String, TrustedDocumentError> {
167        if let Some(doc_id) = document_id {
168            let hash = doc_id.strip_prefix("sha256:").unwrap_or(doc_id);
169            return self.documents.get(hash).map(|r| r.value().clone()).ok_or_else(|| {
170                TrustedDocumentError::DocumentNotFound {
171                    id: doc_id.to_string(),
172                }
173            });
174        }
175        match self.mode {
176            TrustedDocumentMode::Strict => Err(TrustedDocumentError::ForbiddenRawQuery),
177            TrustedDocumentMode::Permissive => {
178                raw_query.map(|s| s.to_string()).ok_or(TrustedDocumentError::ForbiddenRawQuery)
179            },
180        }
181    }
182}
183
184/// Normalize manifest keys: strip "sha256:" prefix for uniform lookup.
185fn normalize_keys(documents: HashMap<String, String>) -> DashMap<String, String> {
186    let out = DashMap::with_capacity(documents.len());
187    for (k, v) in documents {
188        let key = k.strip_prefix("sha256:").unwrap_or(&k).to_string();
189        out.insert(key, v);
190    }
191    out
192}
193
194/// Errors from trusted document resolution.
195#[derive(Debug, thiserror::Error)]
196#[non_exhaustive]
197pub enum TrustedDocumentError {
198    /// Raw queries are not permitted in strict mode.
199    #[error("Raw queries are not permitted. Send a documentId instead.")]
200    ForbiddenRawQuery,
201
202    /// The requested document ID was not found in the manifest.
203    #[error("Unknown document: {id}")]
204    DocumentNotFound {
205        /// The document ID that was requested but not found.
206        id: String,
207    },
208
209    /// Failed to load the manifest file.
210    #[error("Manifest load error: {0}")]
211    ManifestLoad(String),
212}
213
214// ── Metrics ──────────────────────────────────────────────────────────────
215
216static TRUSTED_DOC_HITS: AtomicU64 = AtomicU64::new(0);
217static TRUSTED_DOC_MISSES: AtomicU64 = AtomicU64::new(0);
218static TRUSTED_DOC_REJECTED: AtomicU64 = AtomicU64::new(0);
219
220/// Record a trusted document cache hit.
221pub fn record_hit() {
222    TRUSTED_DOC_HITS.fetch_add(1, Ordering::Relaxed);
223}
224
225/// Record a trusted document miss (unknown document ID).
226pub fn record_miss() {
227    TRUSTED_DOC_MISSES.fetch_add(1, Ordering::Relaxed);
228}
229
230/// Record a rejected raw query (strict mode).
231pub fn record_rejected() {
232    TRUSTED_DOC_REJECTED.fetch_add(1, Ordering::Relaxed);
233}
234
235/// Total trusted document hits.
236pub fn hits_total() -> u64 {
237    TRUSTED_DOC_HITS.load(Ordering::Relaxed)
238}
239
240/// Total trusted document misses.
241pub fn misses_total() -> u64 {
242    TRUSTED_DOC_MISSES.load(Ordering::Relaxed)
243}
244
245/// Total rejected raw queries.
246pub fn rejected_total() -> u64 {
247    TRUSTED_DOC_REJECTED.load(Ordering::Relaxed)
248}