fraiseql_core/runtime/executor/security.rs
1//! Security-aware execution — field access, RBAC filtering, JWT inject resolution,
2//! `execute_with_context()`, `execute_with_security()`, `execute_json()`.
3
4use std::time::Duration;
5
6use super::{Executor, QueryType};
7use crate::{
8 db::traits::DatabaseAdapter,
9 error::{FraiseQLError, Result},
10 runtime::{ExecutionContext, classify_field_access},
11 security::{FieldAccessError, SecurityContext},
12};
13
14impl<A: DatabaseAdapter> Executor<A> {
15 /// Validate that user has access to all requested fields.
16 pub(super) fn validate_field_access(
17 &self,
18 query: &str,
19 variables: Option<&serde_json::Value>,
20 user_scopes: &[String],
21 filter: &crate::security::FieldFilter,
22 ) -> Result<()> {
23 // Parse query to get field selections
24 let query_match = self.matcher.match_query(query, variables)?;
25
26 // Get the return type name from the query definition
27 let type_name = &query_match.query_def.return_type;
28
29 // Validate each requested field
30 let field_refs: Vec<&str> = query_match.fields.iter().map(String::as_str).collect();
31 let errors = filter.validate_fields(type_name, &field_refs, user_scopes);
32
33 if errors.is_empty() {
34 Ok(())
35 } else {
36 // Return the first error (could aggregate all errors if desired)
37 let first_error = &errors[0];
38 Err(FraiseQLError::Authorization {
39 message: first_error.message.clone(),
40 action: Some("read".to_string()),
41 resource: Some(format!("{}.{}", first_error.type_name, first_error.field_name)),
42 })
43 }
44 }
45
46 /// Execute a GraphQL query with cancellation support via `ExecutionContext`.
47 ///
48 /// This method allows graceful cancellation of long-running queries through a
49 /// cancellation token. If the token is cancelled during execution, the query
50 /// returns a `FraiseQLError::Cancelled` error.
51 ///
52 /// # Arguments
53 ///
54 /// * `query` - GraphQL query string
55 /// * `variables` - Query variables (optional)
56 /// * `ctx` - `ExecutionContext` with cancellation token
57 ///
58 /// # Returns
59 ///
60 /// GraphQL response as JSON string, or error if cancelled or execution fails
61 ///
62 /// # Errors
63 ///
64 /// * [`FraiseQLError::Cancelled`] — the cancellation token was triggered before or during
65 /// execution.
66 /// * Propagates any error from the underlying [`execute`](Self::execute) call.
67 ///
68 /// # Example
69 ///
70 /// ```no_run
71 /// // Requires: a live database adapter and running tokio runtime.
72 /// // See: tests/integration/ for runnable examples.
73 /// use fraiseql_core::runtime::ExecutionContext;
74 /// use fraiseql_core::error::FraiseQLError;
75 /// use std::time::Duration;
76 ///
77 /// let ctx = ExecutionContext::new("user-query-123".to_string());
78 /// let cancel_token = ctx.cancellation_token().clone();
79 ///
80 /// // Spawn a task to cancel after 5 seconds
81 /// tokio::spawn(async move {
82 /// tokio::time::sleep(Duration::from_secs(5)).await;
83 /// cancel_token.cancel();
84 /// });
85 ///
86 /// // let result = executor.execute_with_context(query, None, &ctx).await;
87 /// ```
88 pub async fn execute_with_context(
89 &self,
90 query: &str,
91 variables: Option<&serde_json::Value>,
92 ctx: &ExecutionContext,
93 ) -> Result<String> {
94 // Check if already cancelled before starting
95 if ctx.is_cancelled() {
96 return Err(FraiseQLError::cancelled(
97 ctx.query_id().to_string(),
98 "Query cancelled before execution".to_string(),
99 ));
100 }
101
102 let token = ctx.cancellation_token().clone();
103
104 // Use tokio::select! to race between execution and cancellation
105 tokio::select! {
106 result = self.execute(query, variables) => {
107 result
108 }
109 () = token.cancelled() => {
110 Err(FraiseQLError::cancelled(
111 ctx.query_id().to_string(),
112 "Query cancelled during execution".to_string(),
113 ))
114 }
115 }
116 }
117
118 /// Execute a GraphQL query or mutation with a JWT [`SecurityContext`].
119 ///
120 /// This is the **main authenticated entry point** for the executor. It routes the
121 /// incoming request to the appropriate handler based on the query type:
122 ///
123 /// - **Regular queries**: RLS `WHERE` clauses are applied so each user only sees their own
124 /// rows, as determined by the RLS policy in `RuntimeConfig`.
125 /// - **Mutations**: The security context is forwarded to `execute_mutation_query_with_security`
126 /// so server-side `inject` parameters (e.g. `jwt:sub`) are resolved from the caller's JWT
127 /// claims.
128 /// - **Aggregations, window queries, federation, introspection**: Delegated to their respective
129 /// handlers (security context is not yet applied to these).
130 ///
131 /// If `query_timeout_ms` is non-zero in the `RuntimeConfig`, the entire
132 /// execution is raced against a Tokio deadline and returns
133 /// [`FraiseQLError::Timeout`] when the deadline is exceeded.
134 ///
135 /// # Arguments
136 ///
137 /// * `query` - GraphQL query string (e.g. `"query { posts { id title } }"`)
138 /// * `variables` - Optional JSON object of GraphQL variable values
139 /// * `security_context` - Authenticated user context extracted from a validated JWT
140 ///
141 /// # Returns
142 ///
143 /// A JSON-encoded GraphQL response string on success, conforming to the
144 /// [GraphQL over HTTP](https://graphql.github.io/graphql-over-http/) specification.
145 ///
146 /// # Errors
147 ///
148 /// * [`FraiseQLError::Parse`] — the query string is not valid GraphQL
149 /// * [`FraiseQLError::Validation`] — unknown mutation name, missing `sql_source`, or a mutation
150 /// requires `inject` params but the security context is absent
151 /// * [`FraiseQLError::Database`] — the underlying adapter returns an error
152 /// * [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`
153 ///
154 /// # Example
155 ///
156 /// ```no_run
157 /// // Requires: a live database adapter and a SecurityContext from authentication.
158 /// // See: tests/integration/ for runnable examples.
159 /// use fraiseql_core::security::SecurityContext;
160 ///
161 /// // let query = r#"query { posts { id title } }"#;
162 /// // Returns a JSON string: {"data":{"posts":[...]}}
163 /// // let result = executor.execute_with_security(query, None, &context).await?;
164 /// ```
165 pub async fn execute_with_security(
166 &self,
167 query: &str,
168 variables: Option<&serde_json::Value>,
169 security_context: &SecurityContext,
170 ) -> Result<String> {
171 // Apply query timeout if configured
172 if self.config.query_timeout_ms > 0 {
173 let timeout_duration = Duration::from_millis(self.config.query_timeout_ms);
174 tokio::time::timeout(
175 timeout_duration,
176 self.execute_with_security_internal(query, variables, security_context),
177 )
178 .await
179 .map_err(|_| {
180 let query_snippet = if query.len() > 100 {
181 format!("{}...", &query[..100])
182 } else {
183 query.to_string()
184 };
185 FraiseQLError::Timeout {
186 timeout_ms: self.config.query_timeout_ms,
187 query: Some(query_snippet),
188 }
189 })?
190 } else {
191 self.execute_with_security_internal(query, variables, security_context).await
192 }
193 }
194
195 /// Internal execution logic with security context (called by `execute_with_security` with
196 /// timeout wrapper).
197 async fn execute_with_security_internal(
198 &self,
199 query: &str,
200 variables: Option<&serde_json::Value>,
201 security_context: &SecurityContext,
202 ) -> Result<String> {
203 // 1. Classify query type
204 let query_type = self.classify_query(query)?;
205
206 // 2. Route to appropriate handler (with RLS support for regular queries)
207 match query_type {
208 QueryType::Regular => {
209 self.execute_regular_query_with_security(query, variables, security_context)
210 .await
211 },
212 // Other query types don't support RLS yet (relay is handled inside
213 // execute_regular_query_with_security)
214 QueryType::Aggregate(query_name) => {
215 self.execute_aggregate_dispatch(&query_name, variables).await
216 },
217 QueryType::Window(query_name) => {
218 self.execute_window_dispatch(&query_name, variables).await
219 },
220 #[cfg(feature = "federation")]
221 QueryType::Federation(query_name) => {
222 self.execute_federation_query(&query_name, query, variables).await
223 },
224 #[cfg(not(feature = "federation"))]
225 QueryType::Federation(_) => {
226 let _ = (query, variables);
227 Err(FraiseQLError::Validation {
228 message: "Federation is not enabled in this build".to_string(),
229 path: None,
230 })
231 },
232 QueryType::IntrospectionSchema => Ok(self.introspection.schema_response.clone()),
233 QueryType::IntrospectionType(type_name) => {
234 Ok(self.introspection.get_type_response(&type_name))
235 },
236 QueryType::Mutation { name, selection_fields } => {
237 self.execute_mutation_query_with_security(
238 &name,
239 variables,
240 Some(security_context),
241 &selection_fields,
242 )
243 .await
244 },
245 QueryType::NodeQuery => self.execute_node_query(query, variables).await,
246 }
247 }
248
249 /// Check if a specific field can be accessed with given scopes.
250 ///
251 /// This is a convenience method for checking field access without executing a query.
252 ///
253 /// # Arguments
254 ///
255 /// * `type_name` - The GraphQL type name
256 /// * `field_name` - The field name
257 /// * `user_scopes` - User's scopes from JWT token
258 ///
259 /// # Returns
260 ///
261 /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
262 ///
263 /// # Errors
264 ///
265 /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
266 /// required scope for the field.
267 pub fn check_field_access(
268 &self,
269 type_name: &str,
270 field_name: &str,
271 user_scopes: &[String],
272 ) -> std::result::Result<(), FieldAccessError> {
273 if let Some(ref filter) = self.config.field_filter {
274 filter.can_access(type_name, field_name, user_scopes)
275 } else {
276 // No filter configured, allow all access
277 Ok(())
278 }
279 }
280
281 /// Apply field-level RBAC filtering to projection fields.
282 ///
283 /// Classifies each requested field against the user's security context:
284 /// - **Allowed**: user has the required scope (or field is public)
285 /// - **Masked**: user lacks scope, but `on_deny = Mask` → field value will be nulled
286 /// - **Rejected**: user lacks scope, `on_deny = Reject` → query fails with FORBIDDEN
287 ///
288 /// # Errors
289 ///
290 /// Returns `FraiseQLError::Forbidden` if any requested field has `on_deny = Reject`
291 /// and the user lacks the required scope.
292 pub(super) fn apply_field_rbac_filtering(
293 &self,
294 return_type: &str,
295 projection_fields: Vec<String>,
296 security_context: &SecurityContext,
297 ) -> Result<super::super::field_filter::FieldAccessResult> {
298 use super::super::field_filter::FieldAccessResult;
299
300 // Try to extract security config from compiled schema
301 if let Some(security_config) = self.schema.security.as_ref() {
302 if let Some(type_def) = self.schema.types.iter().find(|t| t.name == return_type) {
303 return classify_field_access(
304 security_context,
305 security_config,
306 &type_def.fields,
307 projection_fields,
308 )
309 .map_err(|rejected_field| FraiseQLError::Authorization {
310 message: format!(
311 "Access denied: field '{rejected_field}' on type '{return_type}' \
312 requires a scope you do not have"
313 ),
314 action: Some("read".to_string()),
315 resource: Some(format!("{return_type}.{rejected_field}")),
316 });
317 }
318 }
319
320 // No security config or type not found → all fields allowed, none masked
321 Ok(FieldAccessResult {
322 allowed: projection_fields,
323 masked: Vec::new(),
324 })
325 }
326
327 /// Execute a query and return parsed JSON.
328 ///
329 /// Same as `execute()` but returns parsed `serde_json::Value` instead of string.
330 ///
331 /// # Errors
332 ///
333 /// Propagates all errors from [`Self::execute`] and additionally returns
334 /// [`FraiseQLError::Database`] if the response string is not valid JSON.
335 pub async fn execute_json(
336 &self,
337 query: &str,
338 variables: Option<&serde_json::Value>,
339 ) -> Result<serde_json::Value> {
340 let result_str = self.execute(query, variables).await?;
341 Ok(serde_json::from_str(&result_str)?)
342 }
343}