fraiseql_server/realtime/context_hash.rs
1//! Security context hashing for RLS group coalescing.
2//!
3//! Clients with identical security contexts (same user, roles, tenant, and
4//! scopes) share a single RLS evaluation per event rather than one per
5//! connection. This module provides the hash function that groups them.
6//!
7//! # In-memory only
8//!
9//! Hashes produced by [`security_context_hash`] are **never persisted or
10//! compared across processes**. `ahash` is not stable across versions or
11//! platforms, which is acceptable here. If hashes ever need to be persisted,
12//! switch to a stable algorithm (SipHash-2-4 or BLAKE3).
13
14use std::hash::{Hash, Hasher};
15
16use ahash::AHasher;
17
18/// Borrowed view of the identity fields used for context hashing.
19///
20/// All fields that determine *who* a user is from an RLS perspective
21/// are included. Fields that are per-request metadata (`request_id`,
22/// `ip_address`) are intentionally excluded so that two requests from
23/// the same principal share the same hash.
24pub struct SecurityContextHashInput<'a> {
25 /// User identifier (from JWT `sub` claim).
26 pub user_id: &'a str,
27 /// User's roles.
28 pub roles: &'a [&'a str],
29 /// Tenant/organisation identifier.
30 pub tenant_id: Option<&'a str>,
31 /// OAuth/permission scopes.
32 pub scopes: &'a [&'a str],
33}
34
35/// Compute a stable in-memory hash for a security context.
36///
37/// The hash is order-independent for roles and scopes: two inputs with the
38/// same elements in a different order produce the same hash.
39///
40/// # In-memory only
41///
42/// This hash is suitable for runtime grouping only. Do **not** persist it
43/// or compare values across process restarts.
44#[must_use]
45pub fn security_context_hash(ctx: &SecurityContextHashInput<'_>) -> u64 {
46 let mut hasher = AHasher::default();
47
48 ctx.user_id.hash(&mut hasher);
49
50 // Sort roles so hash is order-independent, then feed each element to the hasher.
51 let mut roles: Vec<&str> = ctx.roles.to_vec();
52 roles.sort_unstable();
53 for role in &roles {
54 role.hash(&mut hasher);
55 }
56
57 ctx.tenant_id.hash(&mut hasher);
58
59 // Sort scopes so hash is order-independent, then feed each element to the hasher.
60 let mut scopes: Vec<&str> = ctx.scopes.to_vec();
61 scopes.sort_unstable();
62 for scope in &scopes {
63 scope.hash(&mut hasher);
64 }
65
66 hasher.finish()
67}