fraiseql_functions/host/live/mod.rs
1//! Live implementation of `HostContext` with real backends.
2//!
3//! This module provides `LiveHostContext` which integrates with actual FraiseQL services:
4//! - GraphQL query execution via `fraiseql-core::Executor`
5//! - Raw SQL statement classification only — `sql_query` has **no execution backend** and fails
6//! loud (#871); use the GraphQL bridge instead
7//! - HTTP requests with SSRF protection
8//! - Storage access with RLS checks
9//! - Auth context and environment variable access
10
11#[cfg(test)]
12mod tests;
13
14pub mod http_validator;
15pub mod sql_classifier;
16pub mod storage;
17
18use std::{collections::HashSet, sync::Arc};
19
20use fraiseql_core::security::SecurityContext;
21use fraiseql_error::Result;
22
23use crate::{
24 HostContext,
25 types::{EventPayload, LogEntry, LogLevel},
26};
27
28/// Configuration for host context operations.
29#[derive(Debug, Clone)]
30pub struct HostContextConfig {
31 /// Allowed domains for outbound HTTP requests (glob patterns).
32 ///
33 /// Deny-by-default: empty (the default) permits no outbound host. A caller
34 /// must explicitly allow each domain.
35 pub allowed_domains: Vec<String>,
36
37 /// Allowed environment variables to expose to functions.
38 pub allowed_env_vars: HashSet<String>,
39
40 /// Maximum response size for HTTP requests (bytes).
41 pub max_http_response_bytes: usize,
42
43 /// Connect timeout for HTTP requests (milliseconds).
44 pub http_connect_timeout_ms: u64,
45
46 /// Read timeout for HTTP requests (milliseconds).
47 pub http_read_timeout_ms: u64,
48
49 /// Maximum size for storage uploads (bytes).
50 pub max_storage_upload_bytes: usize,
51}
52
53impl Default for HostContextConfig {
54 fn default() -> Self {
55 Self {
56 // Deny-by-default (fail-closed): no outbound host is permitted until
57 // the caller explicitly populates the allowlist.
58 allowed_domains: vec![],
59 allowed_env_vars: HashSet::new(),
60 max_http_response_bytes: 10 * 1024 * 1024, // 10 MB
61 http_connect_timeout_ms: 5000,
62 http_read_timeout_ms: 30000,
63 max_storage_upload_bytes: 100 * 1024 * 1024, // 100 MB
64 }
65 }
66}
67
68/// A trait for executing GraphQL queries.
69/// This abstraction allows testing without needing a real database.
70pub trait QueryExecutor: Send + Sync {
71 /// Execute a GraphQL query and return the result.
72 fn execute_query(
73 &self,
74 query: &str,
75 variables: Option<&serde_json::Value>,
76 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send + '_>>;
77}
78
79/// Live host context implementation with real backends.
80pub struct LiveHostContext {
81 /// Event payload that triggered this function.
82 event_payload: EventPayload,
83
84 /// Configuration for host operations.
85 config: HostContextConfig,
86
87 /// Captured log entries.
88 logs: Arc<std::sync::Mutex<Vec<LogEntry>>>,
89
90 /// Query executor for GraphQL execution.
91 query_executor: Option<Arc<dyn QueryExecutor>>,
92
93 /// HTTP client for outbound requests.
94 http_client: Option<Arc<reqwest::Client>>,
95
96 /// Storage backend for file operations.
97 pub storage_backend: Option<Arc<dyn storage::StorageBackend>>,
98
99 /// The identity this invocation runs as — the triggering caller's
100 /// authenticated context where one exists, else the function's `run_as`
101 /// identity. `None` → `auth_context()` and `send_email` fail loud (#803:
102 /// the pre-fix hard-coded anonymous placeholder made every dispatched
103 /// function see a fabricated identity, silently).
104 security_context: Option<SecurityContext>,
105
106 /// Sender-identity resolver for `send_email` — resolves the host-owned `from`
107 /// from the authenticated context. `None` → `send_email` is unconfigured and
108 /// fails loud (mirrors the `sql_query` fail-loud-until-wired stance).
109 sender_resolver: Option<Arc<dyn crate::outbound::SenderIdentityResolver>>,
110
111 /// Email transport for `send_email`. `None` → `send_email` fails loud.
112 email_transport: Option<Arc<dyn crate::outbound::EmailTransport>>,
113
114 /// Per-dispatch idempotency token, injected by the durable dispatcher. `None`
115 /// on non-dispatched paths (bare `invoke`). Stable across retries of the same
116 /// dispatch and distinct per dispatch — see
117 /// [`derive_idempotency_token`](fraiseql_observers::derive_idempotency_token).
118 idempotency_token: Option<String>,
119
120 /// Durable-cursor binding for a Model B scheduled source (#573). `None` on every
121 /// non-source invocation, so `cursor()` / `advance_cursor()` are inert there.
122 source_cursor: Option<SourceCursorBinding>,
123}
124
125/// Binds a [`LiveHostContext`] to exactly one source's durable cursor (Model B).
126///
127/// The guest can only read/advance *its own* source's cursor — the source name is
128/// fixed by the host at construction, never supplied by the guest, so a source
129/// cannot touch another's watermark.
130struct SourceCursorBinding {
131 /// The source this host is bound to (the cursor row + isolation key).
132 source_name: String,
133 /// The durable cursor store.
134 store: fraiseql_observers::PostgresSourceCursorStore,
135}
136
137impl LiveHostContext {
138 /// Create a new live host context.
139 #[must_use]
140 pub fn new(event_payload: EventPayload, config: HostContextConfig) -> Self {
141 Self {
142 event_payload,
143 config,
144 logs: Arc::new(std::sync::Mutex::new(Vec::new())),
145 query_executor: None,
146 http_client: None,
147 storage_backend: None,
148 security_context: None,
149 sender_resolver: None,
150 email_transport: None,
151 idempotency_token: None,
152 source_cursor: None,
153 }
154 }
155
156 /// Attach a query executor, enabling [`query`](HostContext::query) — the
157 /// `fraiseql_query` guest bridge.
158 ///
159 /// A chainable builder (like [`with_source_cursor`](Self::with_source_cursor)
160 /// and [`with_email`](Self::with_email)), so a scheduled Model B source can bind
161 /// both its executor and its cursor onto one [`new`](Self::new) host.
162 #[must_use]
163 pub fn with_executor(mut self, executor: Arc<dyn QueryExecutor>) -> Self {
164 self.query_executor = Some(executor);
165 self
166 }
167
168 /// Create a new live host context with an HTTP client.
169 #[must_use]
170 pub fn with_http_client(
171 event_payload: EventPayload,
172 config: HostContextConfig,
173 http_client: Arc<reqwest::Client>,
174 ) -> Self {
175 Self {
176 event_payload,
177 config,
178 logs: Arc::new(std::sync::Mutex::new(Vec::new())),
179 query_executor: None,
180 http_client: Some(http_client),
181 storage_backend: None,
182 security_context: None,
183 sender_resolver: None,
184 email_transport: None,
185 idempotency_token: None,
186 source_cursor: None,
187 }
188 }
189
190 /// Wire the identity this invocation runs as (#803): the triggering
191 /// caller's authenticated context where one exists, else the function's
192 /// `run_as` identity. Every production dispatch path (durable dispatcher,
193 /// cron poller, source poller) must call this; without it,
194 /// [`auth_context`](HostContext::auth_context) and
195 /// [`send_email`](HostContext::send_email) fail loud rather than fabricate
196 /// an anonymous identity.
197 #[must_use]
198 pub fn with_security_context(mut self, security_context: SecurityContext) -> Self {
199 self.security_context = Some(security_context);
200 self
201 }
202
203 /// Get captured logs (for testing).
204 ///
205 /// # Panics
206 ///
207 /// Panics if the Mutex is poisoned (should never happen in normal operation).
208 #[must_use]
209 pub fn captured_logs(&self) -> Vec<LogEntry> {
210 self.logs.lock().expect("log mutex poisoned").clone()
211 }
212
213 /// Attach a sender-identity resolver and email transport, enabling
214 /// [`send_email`](HostContext::send_email).
215 ///
216 /// The resolver produces the host-owned `from` from the authenticated context
217 /// (the #539 seam — `LoginEmailSender` by default, a DB-backed resolver where
218 /// the sending mailbox differs from the login email); the transport relays the
219 /// message. Without both, `send_email` fails loud.
220 #[must_use]
221 pub fn with_email(
222 mut self,
223 sender_resolver: Arc<dyn crate::outbound::SenderIdentityResolver>,
224 email_transport: Arc<dyn crate::outbound::EmailTransport>,
225 ) -> Self {
226 self.sender_resolver = Some(sender_resolver);
227 self.email_transport = Some(email_transport);
228 self
229 }
230
231 /// Attach the per-dispatch idempotency token surfaced to the guest via
232 /// [`idempotency_token`](HostContext::idempotency_token).
233 ///
234 /// The durable dispatcher derives the token once (from the dispatch's stable
235 /// identity) and sets it on the fresh host it builds for every retry attempt,
236 /// so the guest observes the same token on each attempt.
237 #[must_use]
238 pub fn with_idempotency_token(mut self, token: impl Into<String>) -> Self {
239 self.idempotency_token = Some(token.into());
240 self
241 }
242
243 /// Bind this host to a Model B source's durable cursor (#573). `source_name`
244 /// fixes which cursor row the guest's `fraiseql_cursor_get` /
245 /// `fraiseql_cursor_advance` ops read and write; the guest cannot name another.
246 #[must_use]
247 pub fn with_source_cursor(
248 mut self,
249 source_name: impl Into<String>,
250 store: fraiseql_observers::PostgresSourceCursorStore,
251 ) -> Self {
252 self.source_cursor = Some(SourceCursorBinding {
253 source_name: source_name.into(),
254 store,
255 });
256 self
257 }
258}
259
260// Reason: `HostContext` is async because the live implementation performs I/O —
261// GraphQL queries, HTTP calls, secret lookups. Impls whose methods refuse or
262// answer from memory still have to present the awaited signature the trait
263// defines and every guest call site uses.
264#[allow(unknown_lints, clippy::unused_async_trait_impl)]
265impl HostContext for LiveHostContext {
266 async fn query(
267 &self,
268 graphql: &str,
269 variables: serde_json::Value,
270 ) -> Result<serde_json::Value> {
271 // Check if query executor is available
272 let executor = self.query_executor.as_ref().ok_or_else(|| {
273 fraiseql_error::FraiseQLError::Unsupported {
274 message: "query executor not configured".to_string(),
275 }
276 })?;
277
278 // Execute the query through the executor
279 executor.execute_query(graphql, Some(&variables)).await
280 }
281
282 async fn sql_query(
283 &self,
284 sql: &str,
285 _params: &[serde_json::Value],
286 ) -> Result<Vec<serde_json::Value>> {
287 // Classify the SQL statement first
288 let classification = sql_classifier::classify_sql(sql)?;
289 match classification {
290 sql_classifier::SqlClassification::ReadOnly => {
291 // The query passed read-only classification, but execution is not
292 // wired. Returning `Ok(vec![])` (M-sql-query-stub) made a valid
293 // SELECT look like it ran and returned zero rows; fail loud instead.
294 Err(fraiseql_error::FraiseQLError::Unsupported {
295 message: "sql_query host function is not implemented: the statement \
296 was accepted as read-only but no execution backend is wired"
297 .to_string(),
298 })
299 },
300 sql_classifier::SqlClassification::Rejected(reason) => {
301 Err(fraiseql_error::FraiseQLError::Authorization {
302 message: format!("SQL query not allowed: {}", reason),
303 action: Some("execute_sql_query".to_string()),
304 resource: None,
305 })
306 },
307 }
308 }
309
310 async fn http_request(
311 &self,
312 method: &str,
313 url: &str,
314 headers: &[(String, String)],
315 body: Option<&[u8]>,
316 ) -> Result<crate::host::HttpResponse> {
317 // One implementation of the outbound op, shared with the request-serving
318 // host (#1329). The SSRF allowlist, the literal-IP and DNS-rebinding checks,
319 // the disabled redirect policy and the response-size ceiling are the same
320 // code for both, not the same rule written twice — #1329's constraint was
321 // "the SSRF allowlist, unchanged", and a second copy is how it changes.
322 crate::host::outbound_http::perform(
323 &self.config,
324 self.http_client.as_ref(),
325 method,
326 url,
327 headers,
328 body,
329 )
330 .await
331 }
332
333 async fn storage_get(&self, bucket: &str, key: &str) -> Result<Vec<u8>> {
334 let backend = self.storage_backend.as_ref().ok_or_else(|| {
335 fraiseql_error::FraiseQLError::Unsupported {
336 message: "storage backend not configured".to_string(),
337 }
338 })?;
339
340 backend.get(bucket, key).await
341 }
342
343 async fn storage_put(
344 &self,
345 bucket: &str,
346 key: &str,
347 body: &[u8],
348 content_type: &str,
349 ) -> Result<()> {
350 // Check size limit
351 if body.len() > self.config.max_storage_upload_bytes {
352 return Err(fraiseql_error::FraiseQLError::Validation {
353 message: format!(
354 "upload size {} exceeds limit {}",
355 body.len(),
356 self.config.max_storage_upload_bytes
357 ),
358 path: None,
359 });
360 }
361
362 let backend = self.storage_backend.as_ref().ok_or_else(|| {
363 fraiseql_error::FraiseQLError::Unsupported {
364 message: "storage backend not configured".to_string(),
365 }
366 })?;
367
368 backend.put(bucket, key, body, content_type).await
369 }
370
371 async fn send_email(
372 &self,
373 request: &crate::outbound::SendEmailRequest,
374 ) -> Result<crate::outbound::SendEmailResponse> {
375 // Fail loud, not silent, when the op is unconfigured — mirror `sql_query`.
376 let resolver = self.sender_resolver.as_ref().ok_or_else(|| {
377 fraiseql_error::FraiseQLError::Unsupported {
378 message: "send_email is not configured: no sender-identity resolver is wired \
379 (configure a mailbox with an SMTP send half)"
380 .to_string(),
381 }
382 })?;
383 let transport = self.email_transport.as_ref().ok_or_else(|| {
384 fraiseql_error::FraiseQLError::Unsupported {
385 message: "send_email is not configured: no email transport is wired (configure a \
386 mailbox with an SMTP send half)"
387 .to_string(),
388 }
389 })?;
390
391 // The `from` is host-owned: resolve it from the authenticated context, not
392 // from the guest request. A refusal is fail-closed and never falls back to
393 // a shared mailbox. Map the refusal's permanence onto the error status
394 // durable dispatch classifies by: permanent → 403 (dead-letter), transient
395 // → 503 (retry).
396 let auth = self.auth_context()?;
397 let sender = resolver.resolve_sender(&auth).await.map_err(|error| {
398 if error.retryable {
399 fraiseql_error::FraiseQLError::ServiceUnavailable {
400 message: error.message,
401 retry_after: None,
402 }
403 } else {
404 fraiseql_error::FraiseQLError::Authorization {
405 message: error.message,
406 action: Some("send_email".to_string()),
407 resource: None,
408 }
409 }
410 })?;
411
412 // The per-dispatch context: the send-id is the host idempotency token (the
413 // VERP correlation key + exactly-once dedup key); the tenant scopes the
414 // send-status / suppression rows. Both are host-owned, never guest input.
415 // The identity is present here — `auth_context()` above failed loud
416 // otherwise — so the tenant stamp can never silently collapse to NULL
417 // for a tenant-scoped caller (#803 consequence 3).
418 let context = crate::outbound::SendContext {
419 send_id: self.idempotency_token.as_deref(),
420 tenant: self
421 .security_context
422 .as_ref()
423 .and_then(|ctx| ctx.tenant_id.as_ref())
424 .map(|tenant| tenant.as_str()),
425 };
426 transport.send(&sender, request, context).await
427 }
428
429 fn auth_context(&self) -> Result<serde_json::Value> {
430 // Fail loud when no identity was wired (#803) — a fabricated anonymous
431 // context is indistinguishable from a real unauthenticated caller and
432 // silently breaks everything downstream of it (role checks, tenant
433 // scoping, the per-user send policy).
434 let context = self.security_context.as_ref().ok_or_else(|| {
435 fraiseql_error::FraiseQLError::Unsupported {
436 message: "no security context is wired on this host: the dispatch path must \
437 inject the caller's authenticated context or the function's run_as \
438 identity via with_security_context"
439 .to_string(),
440 }
441 })?;
442 Ok(crate::host::auth_context_json(context))
443 }
444
445 fn env_var(&self, name: &str) -> Result<Option<String>> {
446 // A blocked name is an Authorization refusal, never a silent null
447 // (#840): `Ok(None)` made "the allowlist refused you" indistinguishable
448 // from "the variable is unset", so a mis-scoped secret read failed with
449 // no diagnostic anywhere. `Ok(None)` is reserved for an allowlisted but
450 // genuinely absent variable.
451 if self.config.allowed_env_vars.contains(name) {
452 Ok(std::env::var(name).ok())
453 } else {
454 Err(fraiseql_error::FraiseQLError::Authorization {
455 message: format!(
456 "environment variable `{name}` is not on this function's allowlist \
457 (grant it via FRAISEQL_FUNCTIONS_ALLOWED_ENV_VARS or [sources] \
458 allowed_env_vars)"
459 ),
460 action: Some("env_var".to_string()),
461 resource: Some(name.to_string()),
462 })
463 }
464 }
465
466 fn event_payload(&self) -> &EventPayload {
467 &self.event_payload
468 }
469
470 fn log(&self, level: LogLevel, message: &str) {
471 let entry = LogEntry {
472 level,
473 message: message.to_string(),
474 timestamp: chrono::Utc::now(),
475 };
476 self.logs.lock().expect("log mutex poisoned").push(entry);
477 }
478
479 fn idempotency_token(&self) -> Option<String> {
480 self.idempotency_token.clone()
481 }
482
483 async fn cursor(&self) -> Result<Option<serde_json::Value>> {
484 use fraiseql_observers::SourceCursorStore;
485 match &self.source_cursor {
486 Some(binding) => {
487 let snapshot =
488 binding.store.load(&binding.source_name).await.map_err(|error| {
489 fraiseql_error::FraiseQLError::database(error.to_string())
490 })?;
491 Ok(snapshot.value)
492 },
493 None => Ok(None),
494 }
495 }
496
497 async fn advance_cursor(&self, value: serde_json::Value) -> Result<()> {
498 use fraiseql_observers::SourceCursorStore;
499 let Some(binding) = &self.source_cursor else {
500 return Err(fraiseql_error::FraiseQLError::validation(
501 "advance_cursor: this function is not a scheduled source (no cursor binding)",
502 ));
503 };
504 // Model B advances after its writes commit; load the current snapshot for the
505 // compare-and-swap `from` version, then advance. Under single-firing there is
506 // no concurrent writer, so a failed CAS means the source overlapped itself.
507 let snapshot = binding
508 .store
509 .load(&binding.source_name)
510 .await
511 .map_err(|error| fraiseql_error::FraiseQLError::database(error.to_string()))?;
512 let applied = binding
513 .store
514 .advance(&binding.source_name, &snapshot, value)
515 .await
516 .map_err(|error| fraiseql_error::FraiseQLError::database(error.to_string()))?;
517 if applied {
518 // Value-free by design: the opaque cursor can carry external-influenced
519 // data, so the watermark movement is logged without its contents (#573
520 // Phase 08 observability).
521 tracing::debug!(source = %binding.source_name, "source cursor advanced");
522 Ok(())
523 } else {
524 Err(fraiseql_error::FraiseQLError::database(
525 "advance_cursor: the cursor moved concurrently (lost the compare-and-swap)",
526 ))
527 }
528 }
529}