Skip to main content

fraiseql_core/cache/
key.rs

1//! Cache key generation for query results.
2//!
3//! # Security Critical
4//!
5//! This module is **security-critical**. Cache keys MUST include variable values
6//! to prevent data leakage between different users or requests. Incorrect key
7//! generation could allow User A to see User B's cached data.
8//!
9//! # Key Composition
10//!
11//! Cache keys are generated from a single-pass ahash over:
12//! 1. Query string bytes
13//! 2. Recursively hashed variable values (canonical ordering)
14//! 3. WHERE clause structure (hashed structurally, not via serde)
15//! 4. Schema version string
16//!
17//! The hasher uses fixed seeds so that keys are deterministic across restarts.
18//!
19//! # Example
20//!
21//! ```rust
22//! use fraiseql_core::cache::generate_cache_key;
23//! use fraiseql_core::db::{WhereClause, WhereOperator};
24//! use serde_json::json;
25//!
26//! // Two different users querying their own data
27//! let key1 = generate_cache_key(
28//!     "query { user(id: $id) { name } }",
29//!     &json!({"id": "alice"}),
30//!     None,
31//!     "v1"
32//! );
33//!
34//! let key2 = generate_cache_key(
35//!     "query { user(id: $id) { name } }",
36//!     &json!({"id": "bob"}),
37//!     None,
38//!     "v1"
39//! );
40//!
41//! // Different variables MUST produce different keys (security requirement)
42//! assert_ne!(key1, key2);
43//! ```
44
45use std::hash::{BuildHasher, Hash, Hasher};
46
47use ahash::RandomState;
48use serde_json::Value as JsonValue;
49
50use crate::{
51    backend::{OrderByClause, ProjectionRequest, WhereOperator, where_clause::WhereClause},
52    schema::QueryDefinition,
53    security::SecurityContext,
54};
55
56// Fixed seeds for deterministic hashing across process restarts.
57// These are arbitrary constants — changing them invalidates all cached entries.
58const SEED_K0: u64 = 0x5241_4953_454F_4E31; // "RAISEON1"
59const SEED_K1: u64 = 0x4652_4149_5345_514C; // "FRAISEQL"
60const SEED_K2: u64 = 0x4341_4348_454B_4559; // "CACHEKEY"
61const SEED_K3: u64 = 0x5632_5F43_4143_4845; // "V2_CACHE"
62
63/// Create a new hasher from the fixed-seed `RandomState`.
64fn new_hasher() -> impl Hasher {
65    RandomState::with_seeds(SEED_K0, SEED_K1, SEED_K2, SEED_K3).build_hasher()
66}
67
68/// Generate cache key for query result.
69///
70/// # Security Critical
71///
72/// **DIFFERENT VARIABLE VALUES MUST PRODUCE DIFFERENT KEYS** to prevent data
73/// leakage between users. This function feeds the full query, variables, WHERE
74/// clause, and schema version into a single-pass ahash for a fast, deterministic
75/// `u64` key.
76///
77/// # Key Composition
78///
79/// The cache key is a single ahash pass over:
80/// ```text
81/// ahash(
82///   query_bytes          +
83///   hash(variables)      +   ← recursive, canonical key ordering
84///   hash(WHERE_clause)   +   ← structural, not serde-dependent
85///   schema_version_bytes
86/// )
87/// ```
88///
89/// This ensures:
90/// - Same query + variables = same key (cache hit)
91/// - Different variables = different key (security)
92/// - Different WHERE clauses = different key (correctness)
93/// - Schema changes = different key (validity)
94///
95/// # Arguments
96///
97/// * `query` - GraphQL query string
98/// * `variables` - Query variables from GraphQL request (optional)
99/// * `where_clause` - WHERE filter from auto-params (optional)
100/// * `schema_version` - Schema hash from `CompiledSchema`
101///
102/// # Returns
103///
104/// A `u64` cache key suitable for use as a hash-map key.
105///
106/// # Security Examples
107///
108/// ```rust
109/// use fraiseql_core::cache::generate_cache_key;
110/// use serde_json::json;
111///
112/// let query = "query getUser($id: ID!) { user(id: $id) { name } }";
113///
114/// // Different users MUST get different cache keys
115/// let alice_key = generate_cache_key(query, &json!({"id": "alice"}), None, "v1");
116/// let bob_key = generate_cache_key(query, &json!({"id": "bob"}), None, "v1");
117/// assert_ne!(alice_key, bob_key, "Security: different variables must produce different keys");
118///
119/// // Same user MUST get same key (determinism)
120/// let alice_key2 = generate_cache_key(query, &json!({"id": "alice"}), None, "v1");
121/// assert_eq!(alice_key, alice_key2, "Determinism: same inputs must produce same key");
122/// ```
123#[must_use]
124pub fn generate_cache_key(
125    query: &str,
126    variables: &JsonValue,
127    where_clause: Option<&WhereClause>,
128    schema_version: &str,
129) -> u64 {
130    let mut h = new_hasher();
131
132    // Domain-separate the four sections with unique tags so that, e.g.,
133    // a query ending with "v1" and an empty schema_version can never
134    // collide with a shorter query and schema_version = "v1".
135    h.write(b"q:");
136    h.write(query.as_bytes());
137
138    h.write(b"\0v:");
139    hash_json_value(&mut h, variables);
140
141    h.write(b"\0w:");
142    if let Some(wc) = where_clause {
143        h.write_u8(1);
144        hash_where_clause(&mut h, wc);
145    } else {
146        h.write_u8(0);
147    }
148
149    h.write(b"\0s:");
150    h.write(schema_version.as_bytes());
151
152    h.finish()
153}
154
155/// Fast cache key for a view query — **zero heap allocations**.
156///
157/// Hashes `view + where_clause + limit + offset + schema_version` directly
158/// without constructing an intermediate `String` or `serde_json::Value`.
159/// Use this instead of [`generate_cache_key`] in the cache adapter hot path.
160///
161/// Domain tag `"v:"` separates these keys from projection keys (`"p:"`) and
162/// generic query keys (`"q:"`), preventing cross-path collisions.
163///
164/// # Arguments
165///
166/// * `view` - Database view / table name
167/// * `where_clause` - Optional WHERE filter (e.g. from RLS injection)
168/// * `limit` - Optional row limit
169/// * `offset` - Optional row offset
170/// * `schema_version` - Schema hash from `CompiledSchema::content_hash()`
171#[must_use]
172pub fn generate_view_query_key(
173    view: &str,
174    where_clause: Option<&WhereClause>,
175    limit: Option<u32>,
176    offset: Option<u32>,
177    order_by: Option<&[OrderByClause]>,
178    session_vars: &[(&str, &str)],
179    schema_version: &str,
180) -> u64 {
181    let mut h = new_hasher();
182    h.write(b"v:");
183    h.write(view.as_bytes());
184    h.write(b"\0w:");
185    if let Some(wc) = where_clause {
186        h.write_u8(1);
187        hash_where_clause(&mut h, wc);
188    } else {
189        h.write_u8(0);
190    }
191    h.write(b"\0l:");
192    match limit {
193        Some(l) => {
194            h.write_u8(1);
195            h.write_u32(l);
196        },
197        None => h.write_u8(0),
198    }
199    h.write(b"\0o:");
200    match offset {
201        Some(o) => {
202            h.write_u8(1);
203            h.write_u32(o);
204        },
205        None => h.write_u8(0),
206    }
207    h.write(b"\0b:");
208    hash_order_by(&mut h, order_by);
209    h.write(b"\0sv:");
210    hash_session_vars(&mut h, session_vars);
211
212    h.write(b"\0s:");
213    h.write(schema_version.as_bytes());
214    h.finish()
215}
216
217/// Fast cache key for a projection query — **zero heap allocations**.
218///
219/// Like [`generate_view_query_key`] but also hashes the projection template.
220/// Domain tag `"p:"` separates these keys from plain view keys.
221///
222/// The request is destructured without `..`, so a field added to
223/// [`ProjectionRequest`] does not compile until it is either hashed here or
224/// argued out of the key.
225///
226/// # Arguments
227///
228/// * `request` - The read: view, projection, WHERE, ORDER BY, limit and offset
229/// * `session_vars` - The session variables the read runs under (#1373)
230/// * `schema_version` - Schema hash from `CompiledSchema::content_hash()`
231#[must_use]
232pub fn generate_projection_query_key(
233    request: &ProjectionRequest<'_>,
234    session_vars: &[(&str, &str)],
235    schema_version: &str,
236) -> u64 {
237    let ProjectionRequest {
238        view,
239        projection,
240        where_clause,
241        order_by,
242        limit,
243        offset,
244    } = *request;
245    let mut h = new_hasher();
246    h.write(b"p:");
247    h.write(view.as_bytes());
248    h.write(b"\0j:");
249    match projection {
250        Some(p) => {
251            h.write_u8(1);
252            h.write(p.projection_template.as_bytes());
253        },
254        None => h.write_u8(0),
255    }
256    h.write(b"\0w:");
257    if let Some(wc) = where_clause {
258        h.write_u8(1);
259        hash_where_clause(&mut h, wc);
260    } else {
261        h.write_u8(0);
262    }
263    h.write(b"\0l:");
264    match limit {
265        Some(l) => {
266            h.write_u8(1);
267            h.write_u32(l);
268        },
269        None => h.write_u8(0),
270    }
271    h.write(b"\0o:");
272    match offset {
273        Some(o) => {
274            h.write_u8(1);
275            h.write_u32(o);
276        },
277        None => h.write_u8(0),
278    }
279    h.write(b"\0b:");
280    hash_order_by(&mut h, order_by);
281    h.write(b"\0sv:");
282    hash_session_vars(&mut h, session_vars);
283
284    h.write(b"\0s:");
285    h.write(schema_version.as_bytes());
286    h.finish()
287}
288
289/// Hash of the principal a cached result belongs to.
290///
291/// Covers the fields that can change which rows a request is entitled to:
292/// `user_id`, roles, `tenant_id`, scopes and `attributes` (custom RLS policies can
293/// key on any attribute, e.g. "department" or "region"). Roles and scopes are
294/// sorted and attribute keys ordered, so the order a token lists them in does not
295/// fork the entry; every collection is length-prefixed, so a value cannot slide
296/// from one field into the next.
297///
298/// Not hashed: `request_id`, `ip_address`, `authenticated_at`, `expires_at`,
299/// `issuer`, `audience` — they do not change what the principal may see.
300///
301/// Returns `0` when no security context is present (all anonymous requests share
302/// one scope).
303#[must_use]
304pub fn hash_security_context(ctx: Option<&SecurityContext>) -> u64 {
305    let Some(ctx) = ctx else {
306        return 0;
307    };
308
309    let mut h = new_hasher();
310    h.write(b"u:");
311    ctx.user_id.hash(&mut h);
312
313    let mut roles: Vec<&String> = ctx.roles.iter().collect();
314    roles.sort_unstable();
315    h.write(b"r:");
316    h.write_usize(roles.len());
317    for role in roles {
318        role.hash(&mut h);
319    }
320
321    h.write(b"t:");
322    ctx.tenant_id.hash(&mut h);
323
324    let mut scopes: Vec<&String> = ctx.scopes.iter().collect();
325    scopes.sort_unstable();
326    h.write(b"s:");
327    h.write_usize(scopes.len());
328    for scope in scopes {
329        scope.hash(&mut h);
330    }
331
332    let mut attribute_keys: Vec<&String> = ctx.attributes.keys().collect();
333    attribute_keys.sort_unstable();
334    h.write(b"a:");
335    h.write_usize(attribute_keys.len());
336    for key in attribute_keys {
337        key.hash(&mut h);
338        hash_json_value(&mut h, &ctx.attributes[key]);
339    }
340
341    h.finish()
342}
343
344/// Recursively hash a `serde_json::Value` into the given hasher.
345///
346/// Object keys are sorted before hashing so that insertion order does not
347/// affect the output (critical for variable-order independence).
348fn hash_json_value(h: &mut impl Hasher, value: &JsonValue) {
349    // Write a type discriminant so that `null`, `false`, `0`, `""`, `[]`, and `{}`
350    // all produce distinct hashes.
351    match value {
352        JsonValue::Null => h.write_u8(0),
353        JsonValue::Bool(b) => {
354            h.write_u8(1);
355            b.hash(h);
356        },
357        JsonValue::Number(n) => {
358            h.write_u8(2);
359            // Use the canonical string form so that 1.0 and 1 hash identically
360            // when serde represents them the same way.
361            h.write(n.to_string().as_bytes());
362        },
363        JsonValue::String(s) => {
364            h.write_u8(3);
365            h.write(s.as_bytes());
366        },
367        JsonValue::Array(arr) => {
368            h.write_u8(4);
369            h.write_usize(arr.len());
370            for item in arr {
371                hash_json_value(h, item);
372            }
373        },
374        JsonValue::Object(map) => {
375            h.write_u8(5);
376            h.write_usize(map.len());
377            // Sort keys for canonical ordering.
378            let mut keys: Vec<&String> = map.keys().collect();
379            keys.sort_unstable();
380            for key in keys {
381                h.write(key.as_bytes());
382                hash_json_value(h, &map[key]);
383            }
384        },
385    }
386}
387
388/// Hash a `WhereClause` tree structurally.
389///
390/// Uses discriminant tags and recursion so that structurally different clauses
391/// always produce different hash contributions.
392fn hash_where_clause(h: &mut impl Hasher, clause: &WhereClause) {
393    match clause {
394        WhereClause::Field {
395            path,
396            operator,
397            value,
398        } => {
399            h.write_u8(b'F');
400            h.write_usize(path.len());
401            for segment in path {
402                h.write(segment.as_bytes());
403                h.write_u8(0); // separator
404            }
405            hash_where_operator(h, operator);
406            hash_json_value(h, value);
407        },
408        WhereClause::And(clauses) => {
409            h.write_u8(b'A');
410            h.write_usize(clauses.len());
411            for c in clauses {
412                hash_where_clause(h, c);
413            }
414        },
415        WhereClause::Or(clauses) => {
416            h.write_u8(b'O');
417            h.write_usize(clauses.len());
418            for c in clauses {
419                hash_where_clause(h, c);
420            }
421        },
422        WhereClause::Not(inner) => {
423            h.write_u8(b'N');
424            hash_where_clause(h, inner);
425        },
426        // The declared field types are a function of the query's return type,
427        // which the key already covers, so only the annotated subtree needs
428        // hashing. The tag keeps an annotated clause distinct from a bare one.
429        WhereClause::Typed { inner, .. } => {
430            h.write_u8(b'T');
431            hash_where_clause(h, inner);
432        },
433        // The guard carries the caller's row predicate (ruling AH): two callers whose
434        // predicates differ must not share an entry, so it is hashed in full.
435        WhereClause::Guarded {
436            under,
437            guard,
438            inner,
439        } => {
440            h.write_u8(b'G');
441            h.write_usize(under.len());
442            for segment in under {
443                h.write(segment.as_bytes());
444                h.write_u8(0);
445            }
446            hash_where_clause(h, guard);
447            hash_where_clause(h, inner);
448        },
449        // Ruling AL: every part, the predicate included — it is the caller's.
450        WhereClause::KeyIn {
451            path,
452            key_type,
453            view,
454            target_key,
455            predicate,
456        } => {
457            h.write_u8(b'K');
458            for segments in [path, target_key] {
459                h.write_usize(segments.len());
460                for segment in segments {
461                    h.write(segment.as_bytes());
462                    h.write_u8(0);
463                }
464            }
465            h.write(format!("{key_type:?}").as_bytes());
466            h.write_u8(0);
467            h.write(view.as_bytes());
468            h.write_u8(0);
469            hash_where_clause(h, predicate);
470        },
471        // WhereClause is #[non_exhaustive]; unknown variants get a distinct tag
472        // plus their Debug representation as a conservative fallback.
473        _ => {
474            h.write_u8(b'?');
475            h.write(format!("{clause:?}").as_bytes());
476        },
477    }
478}
479
480/// Hash a `WhereOperator` without allocating.
481///
482/// Uses `std::mem::discriminant` for the variant tag (zero-allocation).
483/// Every variant is a unit variant, so the discriminant fully distinguishes it.
484fn hash_where_operator(h: &mut impl Hasher, op: &WhereOperator) {
485    // discriminant is a fixed-size hashable value — no allocation
486    std::mem::discriminant(op).hash(h);
487}
488
489/// Hash an optional `OrderByClause` slice into the given hasher.
490///
491/// Hashes each clause's `storage_key()` (`snake_case`) and `direction` discriminant,
492/// ensuring that different orderings produce different cache keys.
493fn hash_order_by(h: &mut impl Hasher, order_by: Option<&[OrderByClause]>) {
494    match order_by.filter(|c| !c.is_empty()) {
495        Some(clauses) => {
496            h.write_u8(1);
497            h.write_usize(clauses.len());
498            for clause in clauses {
499                let key = clause.storage_key();
500                h.write(key.as_bytes());
501                h.write_u8(clause.direction as u8);
502            }
503        },
504        None => h.write_u8(0),
505    }
506}
507
508/// Extract accessed views from query definition.
509///
510/// We track which database views/tables a query accesses for view-based
511/// cache invalidation. When a mutation modifies a view, we can invalidate
512/// all cached queries that read from that view.
513///
514/// # Current Scope
515///
516/// Currently extracts only the primary SQL source from the query definition.
517/// Does not analyze:
518/// - JOIN clauses (requires compiled SQL)
519/// - Resolver chains (requires runtime context)
520/// - Nested queries (requires query analyzer)
521///
522/// # Future Enhancements
523///
524/// - Extract views from JOIN clauses in compiled SQL
525/// - Extract views from resolver chains
526/// - Support for custom resolver view tracking
527/// - Entity-level tracking (extract IDs from results)
528///
529/// # Arguments
530///
531/// * `query_def` - The compiled query definition from schema
532///
533/// # Returns
534///
535/// List of view/table names accessed by this query
536///
537/// # Examples
538///
539/// ```rust
540/// use fraiseql_core::cache::extract_accessed_views;
541/// use fraiseql_core::schema::QueryDefinition;
542///
543/// let query_def = QueryDefinition::new("users", "User")
544///     .returning_list()
545///     .with_sql_source("v_user");
546///
547/// let views = extract_accessed_views(&query_def);
548/// assert_eq!(views, vec!["v_user"]);
549/// ```
550#[must_use]
551pub fn extract_accessed_views(query_def: &QueryDefinition) -> Vec<String> {
552    let mut views = Vec::new();
553
554    // Add primary SQL source
555    if let Some(sql_source) = &query_def.sql_source {
556        views.push(sql_source.clone());
557    }
558
559    // Add developer-declared secondary views (JOINs, nested queries, etc.)
560    // Required for correct invalidation when a query reads from multiple views.
561    views.extend(query_def.additional_views.iter().cloned());
562
563    views
564}
565
566/// Verify cache key generation is deterministic.
567///
568/// Used in testing to ensure cache hits work correctly.
569/// Same inputs must always produce the same key.
570///
571/// # Arguments
572///
573/// * `query` - GraphQL query string
574/// * `variables` - Query variables
575/// * `schema_version` - Schema version hash
576///
577/// # Returns
578///
579/// `true` if two sequential key generations produce identical keys
580#[cfg(test)]
581#[must_use]
582pub fn verify_deterministic(query: &str, variables: &JsonValue, schema_version: &str) -> bool {
583    let key1 = generate_cache_key(query, variables, None, schema_version);
584    let key2 = generate_cache_key(query, variables, None, schema_version);
585    key1 == key2
586}
587
588/// Hash the session variables a read runs under, independent of the order they arrive
589/// in (#1373).
590///
591/// A view may read request context with `current_setting()`, so the rows a read
592/// returns can depend on any session variable it sets, and only the database knows
593/// whether one does. Every pair is therefore part of the key: two callers share an
594/// entry only when they would have run the read under identical settings. Names are
595/// unique per request, so sorting by name is a total order.
596fn hash_session_vars(h: &mut impl std::hash::Hasher, session_vars: &[(&str, &str)]) {
597    let mut sorted: Vec<&(&str, &str)> = session_vars.iter().collect();
598    sorted.sort_unstable_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
599    h.write_usize(sorted.len());
600    for (name, value) in sorted {
601        h.write(name.as_bytes());
602        h.write_u8(0);
603        h.write(value.as_bytes());
604        h.write_u8(0);
605    }
606}