postrust_server/build.rs
1//! Building the application a host serves.
2//!
3//! Everything between a loaded configuration and a router answering requests:
4//! the pool, the schema cache, the REST surface, GraphQL and the layers around
5//! them. The `postrust` binary serves the result on a socket; the Lambda
6//! adapter hands the same router to the Lambda runtime. One path, so a request
7//! is answered the same way wherever it arrives -- the Lambda adapter used to
8//! be a second copy of the request path, and that copy bound no parameters and
9//! switched to no role.
10
11use crate::app::{self, handle_request};
12use crate::custom;
13use crate::state::AppState;
14use anyhow::Result;
15#[cfg(feature = "graphql")]
16use axum::routing::{get, post};
17use axum::{
18 extract::DefaultBodyLimit,
19 http::{HeaderValue, Method},
20 response::Json,
21 routing::any,
22 Router,
23};
24use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
25use std::sync::Arc;
26use tokio::sync::RwLock;
27use tower_http::cors::{AllowOrigin, Any as CorsAny, CorsLayer};
28use tracing::{error, info};
29
30/// What the host running the application can do beyond answering requests.
31///
32/// A server process lives as long as it likes and owns its sockets. A Lambda
33/// sandbox is frozen between invocations, so a task listening for `NOTIFY` is
34/// asleep exactly when the notification arrives, and API Gateway does not
35/// upgrade a connection to a WebSocket at all.
36#[derive(Clone, Copy, Debug)]
37#[non_exhaustive]
38pub struct Host {
39 /// Long-lived background work: the `NOTIFY` schema reloader
40 /// (`db_channel_enabled`) and the subscription broker's listener.
41 pub background_tasks: bool,
42 /// Upgrading a connection to a WebSocket, which GraphQL subscriptions are
43 /// served over.
44 pub websockets: bool,
45}
46
47impl Host {
48 /// A long-running server process.
49 pub const SERVER: Host = Host {
50 background_tasks: true,
51 websockets: true,
52 };
53
54 /// An AWS Lambda function: no background work, no WebSockets.
55 pub const LAMBDA: Host = Host {
56 background_tasks: false,
57 websockets: false,
58 };
59}
60
61/// The application, ready to serve.
62#[non_exhaustive]
63pub struct App {
64 /// Every route the configuration enables, with its layers and state.
65 pub router: Router,
66 /// The state the routes share, for anything served beside them -- the
67 /// binary's admin port answers health checks from it.
68 pub state: Arc<AppState>,
69}
70
71/// Connect, load the schema cache and build the router.
72///
73/// Fails rather than serving something partial when the database cannot be
74/// reached or a setting cannot be honoured. GraphQL is the exception: a schema
75/// that cannot be built is logged and the rest is served without it.
76pub async fn build(config: postrust_core::AppConfig, host: Host) -> Result<App> {
77 info!("Database: {}", mask_db_uri(&config.db_uri));
78
79 // Create database pool
80 let connect_options: PgConnectOptions = config
81 .db_uri
82 .parse()
83 .map_err(|e| anyhow::anyhow!("invalid database URI: {e}"))?;
84
85 // Prepared statements are sqlx's default and stay on unless asked. Turning
86 // them off is for a connection pooler in transaction mode -- PgBouncer and
87 // the like -- where the server connection a statement was prepared on is
88 // not the one the next query lands on, and every reuse fails with
89 // "prepared statement does not exist".
90 let connect_options = if config.db_prepared_statements {
91 connect_options
92 } else {
93 info!("Prepared statements disabled");
94 connect_options.statement_cache_capacity(0)
95 };
96
97 let pool = PgPoolOptions::new()
98 .max_connections(config.db_pool_size)
99 .acquire_timeout(std::time::Duration::from_secs(config.db_pool_timeout))
100 .connect_with(connect_options)
101 .await?;
102
103 info!("Connected to database");
104
105 // Load schema cache
106 let schema_cache = postrust_core::SchemaCache::load_with_search_path(
107 &pool,
108 &config.db_schemas,
109 &config.db_extra_search_path,
110 )
111 .await?;
112 info!("{}", schema_cache.summary());
113
114 // Create app state
115 let state = Arc::new(AppState {
116 pool,
117 schema_cache: RwLock::new(schema_cache),
118 config: config.clone(),
119 jwt_config: postrust_auth::JwtConfig {
120 secret: config.jwt_secret.clone(),
121 secret_is_base64: config.jwt_secret_is_base64,
122 audience: config.jwt_aud.clone(),
123 role_claim_key: config.jwt_role_claim_key.clone(),
124 anon_role: config.db_anon_role.clone(),
125 },
126 jwt_cache: postrust_auth::JwtCache::new(
127 config.jwt_cache_enabled,
128 config.jwt_cache_max_lifetime,
129 ),
130 });
131
132 // `db_channel_enabled`: reload the schema cache when PostgreSQL says the
133 // schema changed, instead of on a restart. A migration ends with
134 // `NOTIFY pgrst, 'reload schema'` and the API picks the new shape up.
135 //
136 // This refreshes the REST schema cache. The GraphQL schema is built once
137 // from a snapshot taken at start-up and is not rebuilt here.
138 if config.db_channel_enabled && host.background_tasks {
139 spawn_schema_reloader(state.clone(), config.db_channel.clone());
140 }
141
142 // Build REST API router (under /api prefix)
143 let api_router: Router<Arc<AppState>> = Router::new()
144 .route("/", any(handle_request))
145 .route("/{*path}", any(handle_request));
146
147 // Build main router
148 let mut app: Router<Arc<AppState>> = Router::new().nest("/api", api_router);
149
150 // Two endpoints every Hasura deployment has, and that the things around a
151 // deployment reach for without being asked to: `/healthz` is what a load
152 // balancer, a Kubernetes probe and `docker-compose` healthchecks are
153 // already configured to poll, and `/v1/version` is what a client library
154 // calls to decide which features to use. Serving them costs nothing and
155 // not serving them makes a drop-in replacement fail its first health
156 // check.
157 app = app
158 .route("/healthz", axum::routing::get(|| async { "OK" }))
159 .route(
160 "/v1/version",
161 axum::routing::get(|| async {
162 Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") }))
163 }),
164 );
165
166 // Add custom routes (health checks, webhooks, etc.)
167 app = app.nest("/_", custom::custom_router());
168 info!("Custom routes enabled at /_");
169
170 // Add admin routes and GraphQL endpoint if feature is enabled
171 #[cfg(feature = "graphql")]
172 {
173 use async_graphql_axum::GraphQLBatchRequest as GqlRequest;
174 use axum::extract::State as AxumState;
175 use axum::http::HeaderMap;
176 use postrust_graphql::handler::GraphQLState;
177 use postrust_graphql::schema::SchemaConfig;
178
179 #[cfg(feature = "admin-ui")]
180 {
181 info!("Admin UI enabled at /admin");
182 app = app.nest("/admin", crate::admin::admin_router());
183 }
184
185 // Create GraphQL state with subscriptions enabled
186 let schema_cache_snapshot = state.schema_cache.read().await.clone();
187 let schema_cache_arc = Arc::new(schema_cache_snapshot);
188 // What Hasura keeps in metadata and a schema cannot carry: the names,
189 // and what each role may do. Absent, every name is derived exactly as
190 // before and there is no permission layer at all.
191 //
192 // Two spellings because the document outgrew the first one. `_NAMES`
193 // is what it was called when names were all it held, and a deployment
194 // already setting it should not have to change anything.
195 let names_var = ["PGRST_GRAPHQL_METADATA", "PGRST_GRAPHQL_NAMES"]
196 .into_iter()
197 .find_map(|name| std::env::var(name).ok().map(|value| (name, value)));
198
199 let graphql_names = match names_var {
200 Some((var, value)) => match postrust_graphql::names::NameOverrides::parse(&value) {
201 Ok(names) => {
202 if !names.is_empty() {
203 info!("GraphQL names given for {} tables", names.len());
204 }
205 if names.placed_functions() > 0 {
206 info!(
207 "GraphQL roots given for {} functions",
208 names.placed_functions()
209 );
210 }
211 if names.has_permissions() {
212 info!(
213 "GraphQL permissions given for {} roles, {} grants",
214 names.roles().len(),
215 names.granted()
216 );
217 }
218 names
219 }
220 Err(e) => {
221 // Serving the derived names instead would answer every
222 // request under a name the client does not send, which
223 // reads as a broken server rather than a bad setting. A
224 // permission read wrong is worse still: it would serve
225 // rows a rule was written to withhold.
226 tracing::error!("{}: {}", var, e);
227 return Err(anyhow::anyhow!("{}: {}", var, e));
228 }
229 },
230 None => postrust_graphql::names::NameOverrides::default(),
231 };
232
233 // An enum table's members are rows, not schema, so they are read here
234 // rather than reflected. Once, at startup: the values of a set of
235 // allowed values are not expected to change under a running server,
236 // and a GraphQL enum is part of the schema a client generated against.
237 let mut graphql_enum_values: std::collections::HashMap<
238 String,
239 Vec<(String, Option<String>)>,
240 > = std::collections::HashMap::new();
241 {
242 let cache = state.schema_cache.read().await;
243 for (schema, table) in graphql_names.enum_tables() {
244 let qi = postrust_core::api_request::QualifiedIdentifier::new(&schema, &table);
245 let Some(definition) = cache.get_table(&qi) else {
246 tracing::warn!(
247 "{}.{} is marked as an enumeration but was not found",
248 schema,
249 table
250 );
251 continue;
252 };
253 // One column identifies a member. A composite key names no
254 // single value, so there is nothing to call the member.
255 let [key_column] = definition.pk_cols.as_slice() else {
256 tracing::warn!(
257 "{}.{} is marked as an enumeration but its primary key is not one column",
258 schema,
259 table
260 );
261 continue;
262 };
263 // Hasura's convention, and a useful one: a `comment` column
264 // describes each value. Read as a role that may read the
265 // table, which a NOINHERIT authenticator is not by itself.
266 match postrust_core::schema_cache::enum_values(
267 &state.pool,
268 &schema,
269 &table,
270 key_column,
271 definition.get_column("comment").is_some(),
272 )
273 .await
274 {
275 Ok(values) => {
276 graphql_enum_values.insert(format!("{}.{}", schema, table), values);
277 }
278 Err(e) => {
279 tracing::warn!("cannot read the values of {}.{}: {}", schema, table, e)
280 }
281 }
282 }
283 }
284
285 // How often a live query re-reads itself with nothing having
286 // notified it. The notifications do the work; this is the floor
287 // under what a trigger cannot see -- a view, an embedded row on a
288 // table with no trigger, a predicate written against the clock.
289 let subscription_refresh_seconds = std::env::var("PGRST_SUBSCRIPTION_REFRESH")
290 .ok()
291 .and_then(|value| value.trim().parse::<u64>().ok())
292 .unwrap_or(30);
293
294 let mut graphql_config = SchemaConfig::default();
295 // A subscription is a WebSocket, so a host that cannot hold one
296 // does not advertise a `Subscription` root it cannot serve.
297 graphql_config.enable_subscriptions = host.websockets;
298 graphql_config.subscription_refresh_seconds = subscription_refresh_seconds;
299 graphql_config.max_rows = config.db_max_rows;
300 graphql_config.enable_federation = config.graphql_federation;
301 graphql_config.type_prefix = config.graphql_type_prefix.clone();
302 // The GraphQL schema was built for `public` whatever the server
303 // was told to expose, so a table in any other schema of
304 // `PGRST_DB_SCHEMAS` was reachable over REST and invisible over
305 // GraphQL.
306 graphql_config.exposed_schemas = config.db_schemas.clone();
307 graphql_config.enum_values = graphql_enum_values;
308 graphql_config.names = graphql_names;
309 // A schema that cannot be built is a reason to serve without GraphQL,
310 // not a reason to exit. The REST surface, the admin UI and the health
311 // endpoint do not depend on it, and taking the process down with it
312 // turns one unrepresentable table into a server that will not start.
313 let built = GraphQLState::new(state.pool.clone(), schema_cache_arc.clone(), graphql_config);
314 if let Err(e) = &built {
315 tracing::error!(
316 "GraphQL schema could not be built, serving without it: {}",
317 e
318 );
319 }
320 if let Ok(graphql_state) = built {
321 let graphql_state = Arc::new(graphql_state);
322
323 // Initialize subscription broker
324 if !host.websockets {
325 info!("GraphQL subscriptions disabled: this host cannot hold a WebSocket");
326 } else if let Err(e) = graphql_state.init_subscriptions().await {
327 tracing::warn!("Failed to initialize subscription broker: {}. Subscriptions may not work until triggers are created.", e);
328 } else {
329 info!("GraphQL subscriptions enabled");
330 }
331
332 info!("GraphQL endpoint enabled at /api/graphql");
333
334 // Combined state for GraphQL routes (includes JWT config for auth)
335 #[derive(Clone)]
336 struct GraphQLAppState {
337 gql_state: Arc<GraphQLState>,
338 jwt_config: postrust_auth::JwtConfig,
339 hasura_auth: postrust_auth::HasuraAuthConfig,
340 }
341
342 let graphql_app_state = GraphQLAppState {
343 gql_state: graphql_state.clone(),
344 jwt_config: state.jwt_config.clone(),
345 hasura_auth: postrust_auth::HasuraAuthConfig {
346 admin_secret: config.hasura_admin_secret.clone(),
347 unauthorized_role: config.hasura_unauthorized_role.clone(),
348 },
349 };
350
351 // Wrapper handler that creates context from request with proper auth
352 /// A body that is a JSON *array* is a batch: several operations
353 /// in one request, answered with an array of responses in the
354 /// order they were sent. Each is its own operation with its own
355 /// transaction -- batching is a way to save round trips, not a
356 /// way to make several mutations atomic, which is what naming
357 /// several root fields in one mutation is for.
358 async fn handle_graphql(
359 AxumState(app_state): AxumState<GraphQLAppState>,
360 // Which of the three addresses this arrived at. The routes are
361 // nested, so the request's own path is `/` by the time it gets
362 // here and the original is the only thing that still says.
363 axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
364 headers: HeaderMap,
365 req: GqlRequest,
366 ) -> (axum::http::StatusCode, Json<serde_json::Value>) {
367 // The status a refused write is answered with belongs to the
368 // endpoint rather than to the refusal: `/v1alpha1/graphql`
369 // reports it in the transport and `/v1/graphql` does not.
370 let endpoint = match uri.path().starts_with("/v1alpha1/") {
371 true => postrust_graphql::hasura::Endpoint::Legacy,
372 false => postrust_graphql::hasura::Endpoint::Current,
373 };
374 match req.0 {
375 async_graphql::BatchRequest::Single(request) => {
376 let body = one_graphql(app_state, headers, request).await;
377 let status = axum::http::StatusCode::from_u16(
378 postrust_graphql::hasura::status_for(&body, endpoint),
379 )
380 .unwrap_or(axum::http::StatusCode::OK);
381 (status, Json(body))
382 }
383 async_graphql::BatchRequest::Batch(requests) => {
384 let mut answers = Vec::with_capacity(requests.len());
385 for request in requests {
386 answers.push(
387 one_graphql(app_state.clone(), headers.clone(), request).await,
388 );
389 }
390 // A batch is answered 200 whatever its parts say: the
391 // transport carried every one of them, and which of
392 // them failed is in the array.
393 (
394 axum::http::StatusCode::OK,
395 Json(serde_json::Value::Array(answers)),
396 )
397 }
398 }
399 }
400
401 /// Who is asking, settled once: per request over HTTP, and per
402 /// connection over a WebSocket, where the token arrives in the
403 /// `connection_init` payload rather than on every operation.
404 #[derive(Clone)]
405 struct GraphQLIdentity {
406 auth_result: postrust_auth::AuthResult,
407 hasura_role: Option<String>,
408 session: std::collections::HashMap<String, String>,
409 elevated: bool,
410 headers: HeaderMap,
411 }
412
413 /// An operation ready to run against the schema its role answers
414 /// from, and the transaction its writes will share.
415 struct PreparedOperation {
416 schema: async_graphql::dynamic::Schema,
417 request: async_graphql::Request,
418 write: postrust_graphql::context::SharedWrite,
419 }
420
421 /// The caller's identity, or the Hasura-shaped body refusing it.
422 fn graphql_identity(
423 app_state: &GraphQLAppState,
424 headers: HeaderMap,
425 ) -> Result<GraphQLIdentity, serde_json::Value> {
426 // Extract auth header and authenticate
427 let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
428
429 let token = postrust_auth::authenticate(auth_header, &app_state.jwt_config);
430 // A token that was offered and verified. Not the same as
431 // `token.is_ok()`, which is also true of the anonymous
432 // fallback -- and an anonymous request is precisely the one
433 // that has not been authenticated.
434 let token_verified = auth_header.is_some() && token.is_ok();
435
436 let auth_result = match token {
437 Ok(auth) => auth,
438 Err(e) => {
439 tracing::debug!("GraphQL auth failed: {}, using anon role", e);
440 postrust_auth::AuthResult {
441 role: app_state
442 .jwt_config
443 .anon_role
444 .clone()
445 .unwrap_or_else(|| "anon".to_string()),
446 claims: std::collections::HashMap::new(),
447 }
448 }
449 };
450
451 tracing::debug!(
452 "GraphQL request authenticated as role: {}",
453 auth_result.role
454 );
455
456 // Who the caller is in Hasura's sense, which is a different
457 // question from which database user the transaction runs as.
458 // The secret goes first: a caller that tried to be an
459 // administrator and got the secret wrong is refused rather
460 // than asked for a token.
461 let pairs: Vec<(&str, &str)> = headers
462 .iter()
463 .filter_map(|(name, value)| {
464 value.to_str().ok().map(|value| (name.as_str(), value))
465 })
466 .collect();
467
468 let (hasura_role, session, elevated) =
469 match postrust_auth::hasura::from_admin_secret(
470 &app_state.hasura_auth,
471 &pairs[..],
472 ) {
473 postrust_auth::SecretOutcome::Accepted(identity) => {
474 (Some(identity.role), identity.session, identity.elevated)
475 }
476 postrust_auth::SecretOutcome::Rejected => {
477 return Err(postrust_graphql::hasura::denied(
478 postrust_auth::hasura::SECRET_INVALID,
479 ));
480 }
481 // Nothing settled by the secret. A verified token speaks
482 // next, and session variables come from its claims.
483 //
484 // One header is read here and only here: `X-Hasura-Role`,
485 // against the `x-hasura-allowed-roles` the token carries.
486 // That list is inside the signature, so it lets a caller
487 // choose among the identities it was issued and not add
488 // one. Every other `x-hasura-*` header is still ignored --
489 // on a server with no secret to gate them they would let
490 // any caller name its own identity.
491 _ if token_verified => {
492 match postrust_auth::hasura::role_for_token(
493 &auth_result.claims,
494 &pairs[..],
495 ) {
496 postrust_auth::hasura::TokenRole::Is(role) => (
497 role,
498 postrust_auth::hasura::session_from_claims(&auth_result.claims),
499 false,
500 ),
501 postrust_auth::hasura::TokenRole::NotAllowed => {
502 return Err(postrust_graphql::hasura::denied(
503 postrust_auth::hasura::ROLE_NOT_ALLOWED,
504 ));
505 }
506 }
507 }
508 // A secret is configured and nothing authenticated this
509 // request. Answering it as a stranger is a choice the
510 // deployment makes; refusing is the default.
511 _ if app_state.hasura_auth.is_configured() => {
512 match postrust_auth::hasura::unauthenticated(&app_state.hasura_auth) {
513 Some(identity) => (Some(identity.role), identity.session, false),
514 None => {
515 return Err(postrust_graphql::hasura::denied(
516 postrust_auth::hasura::SECRET_MISSING,
517 ));
518 }
519 }
520 }
521 // No Hasura auth configured at all: unchanged from before
522 // any of this existed.
523 _ => (
524 postrust_auth::hasura::role_from_claims(&auth_result.claims),
525 postrust_auth::hasura::session_from_claims(&auth_result.claims),
526 false,
527 ),
528 };
529
530 Ok(GraphQLIdentity {
531 auth_result,
532 hasura_role,
533 session,
534 elevated,
535 headers,
536 })
537 }
538
539 /// Everything between knowing who is asking and running what they
540 /// asked: the checks Hasura makes before reading the document, the
541 /// schema the role answers from, and the context every resolver
542 /// reads the identity from.
543 fn prepare_operation(
544 app_state: &GraphQLAppState,
545 identity: &GraphQLIdentity,
546 req: async_graphql::Request,
547 ) -> Result<PreparedOperation, serde_json::Value> {
548 let GraphQLIdentity {
549 auth_result,
550 hasura_role,
551 session,
552 elevated,
553 headers,
554 } = identity.clone();
555 let pairs: Vec<(&str, &str)> = headers
556 .iter()
557 .filter_map(|(name, value)| {
558 value.to_str().ok().map(|value| (name.as_str(), value))
559 })
560 .collect();
561
562 // A protocol neither server implements, which only one of
563 // them says. See `hasura::persisted_query`.
564 if postrust_graphql::hasura::persisted_query(&req) {
565 return Err(postrust_graphql::hasura::not_supported(
566 "PersistedQueryNotSupported",
567 ));
568 }
569
570 // A header Hasura reads itself, before it reads the
571 // document. `x-hasura-use-backend-only-permissions: random` is
572 // not false, and treating it as false would send a
573 // backend-only write down the path meant for everyone else on
574 // a header the client thought it had set.
575 if let Some(message) = postrust_auth::hasura::unreadable_boolean_header(&pairs[..])
576 {
577 return Err(postrust_graphql::hasura::malformed(&message));
578 }
579
580 // Which schema answers. A permission is a statement about what
581 // exists, so the role decides the shape of the API before it
582 // decides anything about a row: a role the document does not
583 // name has no schema at all, and is refused here rather than
584 // being answered from someone else's.
585 let Some(schema) = app_state.gql_state.schema_for(
586 hasura_role.as_deref(),
587 postrust_auth::hasura::backend_only_requested(&pairs[..], elevated),
588 ) else {
589 return Err(postrust_graphql::hasura::denied(&format!(
590 "role \"{}\" is not defined in the permissions",
591 hasura_role.as_deref().unwrap_or_default()
592 )));
593 };
594
595 // Create SchemaCacheRef from the static Arc<SchemaCache>
596 //
597 // The unreduced cache, deliberately. The schema above decides
598 // what may be asked for; this is what a resolver looks a type
599 // up in once something has been asked, and a superset can only
600 // answer the same questions.
601 let schema_cache_ref = postrust_core::schema_cache::SchemaCacheRef::from_static(
602 (*app_state.gql_state.schema_cache).clone(),
603 );
604
605 // Every write in this operation goes into one transaction, and
606 // this is the half that decides its fate: a mutation naming
607 // several root fields is all-or-nothing, so the second one
608 // failing has to take the first one's rows with it. Nothing to
609 // settle for a query, which never opens it.
610 let write: postrust_graphql::context::SharedWrite = Arc::default();
611
612 let gql_ctx = postrust_graphql::context::GraphQLContext::new(
613 app_state.gql_state.pool.clone(),
614 schema_cache_ref,
615 auth_result,
616 )
617 .with_session(session)
618 .with_identity(hasura_role.clone(), elevated)
619 .with_write(Arc::clone(&write));
620
621 // A role may be told it cannot read the schema as data. Not a
622 // permission on a table, so it is not settled by which schema
623 // answers -- the schema is the thing being withheld.
624 //
625 // Not withheld from a caller holding the admin secret, whatever
626 // role it then names. That is measured rather than assumed: a
627 // v2.50.1 reference given `set_graphql_schema_introspection_
628 // options` answers `__schema` for the named role when the
629 // secret is sent beside the role header, and answers it from
630 // that role's own restricted schema. Reading the schema is an
631 // administrator's to do; the permissions still apply to it.
632 let introspection_disabled_for =
633 hasura_role.as_deref().filter(|_| !elevated).filter(|role| {
634 app_state
635 .gql_state
636 .config
637 .names
638 .introspection_disabled(role)
639 });
640 let prepared = postrust_graphql::hasura::prepare(
641 Some(schema),
642 req,
643 introspection_disabled_for,
644 );
645 let request = match prepared {
646 Ok(request) => request,
647 // Refused before it ran: nothing was written, so there is
648 // nothing to settle.
649 Err((_, errors)) => {
650 let mut response = async_graphql::Response::new(async_graphql::Value::Null);
651 response.errors = errors;
652 return Err(postrust_graphql::hasura::envelope(response));
653 }
654 };
655 let request = request
656 .data(gql_ctx)
657 .data(app_state.gql_state.pool.clone())
658 .data(Arc::clone(&app_state.gql_state.broker));
659 Ok(PreparedOperation {
660 schema: schema.clone(),
661 request,
662 write,
663 })
664 }
665
666 /// Settle an operation's writes before its answer goes out.
667 async fn settle_write(
668 write: &postrust_graphql::context::SharedWrite,
669 response: &mut async_graphql::Response,
670 ) {
671 if let Some(tx) = write.lock().await.take() {
672 let settled = if response.errors.is_empty() {
673 tx.commit().await
674 } else {
675 tx.rollback().await
676 };
677 // A commit that fails is a mutation that did not happen,
678 // however well every statement in it went. Saying so is
679 // the whole point of running them together.
680 if let Err(e) = settled {
681 tracing::error!("GraphQL mutation could not be settled: {}", e);
682 response
683 .errors
684 .push(async_graphql::ServerError::new(e.to_string(), None));
685 response.data = async_graphql::Value::Null;
686 }
687 }
688 }
689
690 async fn one_graphql(
691 app_state: GraphQLAppState,
692 headers: HeaderMap,
693 req: async_graphql::Request,
694 ) -> serde_json::Value {
695 let identity = match graphql_identity(&app_state, headers) {
696 Ok(identity) => identity,
697 Err(refusal) => return refusal,
698 };
699 let PreparedOperation {
700 schema,
701 request,
702 write,
703 } = match prepare_operation(&app_state, &identity, req) {
704 Ok(operation) => operation,
705 Err(refusal) => return refusal,
706 };
707 let mut response = schema.execute(request).await;
708 settle_write(&write, &mut response).await;
709 postrust_graphql::hasura::envelope(response)
710 }
711
712 /// A Hasura-shaped body as the response a WebSocket carries, so a
713 /// subscription is refused, and its errors read, exactly as the
714 /// same operation over HTTP would be.
715 fn hasura_response(body: serde_json::Value) -> async_graphql::Response {
716 let data = body
717 .get("data")
718 .cloned()
719 .and_then(|data| async_graphql::Value::from_json(data).ok())
720 .unwrap_or(async_graphql::Value::Null);
721 let mut response = async_graphql::Response::new(data);
722 for error in body
723 .get("errors")
724 .and_then(serde_json::Value::as_array)
725 .into_iter()
726 .flatten()
727 {
728 let message = error
729 .get("message")
730 .and_then(serde_json::Value::as_str)
731 .unwrap_or_default();
732 let mut server_error = async_graphql::ServerError::new(message, None);
733 if let Some(fields) = error
734 .get("extensions")
735 .and_then(serde_json::Value::as_object)
736 {
737 let mut extensions = async_graphql::ErrorExtensionValues::default();
738 for (name, value) in fields {
739 if let Ok(value) = async_graphql::Value::from_json(value.clone()) {
740 extensions.set(name, value);
741 }
742 }
743 server_error.extensions = Some(extensions);
744 }
745 response.errors.push(server_error);
746 }
747 response
748 }
749
750 /// The headers a WebSocket connection is authenticated with: the
751 /// upgrade request's, overlaid with those the `connection_init`
752 /// payload carries. A browser cannot set headers on a WebSocket,
753 /// so a client puts `Authorization` in the payload -- Hasura's own
754 /// clients under `headers`, others at the top level.
755 fn connection_headers(
756 mut headers: HeaderMap,
757 payload: &serde_json::Value,
758 ) -> HeaderMap {
759 let nested = payload
760 .get("headers")
761 .and_then(serde_json::Value::as_object);
762 for fields in [payload.as_object(), nested].into_iter().flatten() {
763 for (name, value) in fields {
764 let Some(value) = value.as_str() else {
765 continue;
766 };
767 if let (Ok(name), Ok(value)) = (
768 axum::http::HeaderName::from_bytes(name.as_bytes()),
769 axum::http::HeaderValue::from_str(value),
770 ) {
771 headers.insert(name, value);
772 }
773 }
774 }
775 headers
776 }
777
778 /// Runs every WebSocket operation -- query, mutation or
779 /// subscription -- as the identity its connection was initialised
780 /// with, through the same preparation an HTTP request gets.
781 #[derive(Clone)]
782 struct WebSocketExecutor(GraphQLAppState);
783
784 impl async_graphql::Executor for WebSocketExecutor {
785 async fn execute(
786 &self,
787 request: async_graphql::Request,
788 ) -> async_graphql::Response {
789 use async_graphql::futures_util::StreamExt;
790 // Not reached over a WebSocket, which runs everything as a
791 // stream; answered from the stream so the two cannot differ.
792 self.execute_stream(request, None)
793 .next()
794 .await
795 .unwrap_or_else(|| async_graphql::Response::new(async_graphql::Value::Null))
796 }
797
798 fn execute_stream(
799 &self,
800 request: async_graphql::Request,
801 session_data: Option<Arc<async_graphql::Data>>,
802 ) -> async_graphql::futures_util::stream::BoxStream<'static, async_graphql::Response>
803 {
804 use async_graphql::futures_util::{stream, StreamExt};
805 let identity = session_data
806 .as_deref()
807 .and_then(|data| data.get(&std::any::TypeId::of::<GraphQLIdentity>()))
808 .and_then(|identity| identity.downcast_ref::<GraphQLIdentity>())
809 .cloned();
810 // `connection_init` refuses a connection it cannot
811 // identify, so an operation without an identity is one the
812 // protocol let through before initialising. Refused rather
813 // than run as nobody in particular.
814 let Some(identity) = identity else {
815 let refusal =
816 postrust_graphql::hasura::denied("the connection was not initialised");
817 return stream::once(async move { hasura_response(refusal) }).boxed();
818 };
819 match prepare_operation(&self.0, &identity, request) {
820 Err(refusal) => {
821 stream::once(async move { hasura_response(refusal) }).boxed()
822 }
823 Ok(PreparedOperation {
824 schema,
825 request,
826 write,
827 }) => {
828 async_graphql::Executor::execute_stream(&schema, request, session_data)
829 .then(move |mut response| {
830 let write = Arc::clone(&write);
831 async move {
832 settle_write(&write, &mut response).await;
833 hasura_response(postrust_graphql::hasura::envelope(
834 response,
835 ))
836 }
837 })
838 .boxed()
839 }
840 }
841 }
842 }
843
844 /// GraphQL over a WebSocket, authenticated as HTTP is. The
845 /// identity is settled once, from `connection_init`, and a
846 /// connection that would have been refused over HTTP -- a wrong
847 /// admin secret, a role the token does not allow -- is refused
848 /// there rather than served as the anonymous role.
849 async fn graphql_ws(
850 AxumState(app_state): AxumState<GraphQLAppState>,
851 protocol: async_graphql_axum::GraphQLProtocol,
852 headers: HeaderMap,
853 ws: axum::extract::ws::WebSocketUpgrade,
854 ) -> axum::response::Response {
855 ws.protocols(async_graphql::http::ALL_WEBSOCKET_PROTOCOLS)
856 .on_upgrade(move |socket| async move {
857 let init_state = app_state.clone();
858 async_graphql_axum::GraphQLWebSocket::new(
859 socket,
860 WebSocketExecutor(app_state),
861 protocol,
862 )
863 .on_connection_init(move |payload| async move {
864 match graphql_identity(
865 &init_state,
866 connection_headers(headers, &payload),
867 ) {
868 Ok(identity) => {
869 let mut data = async_graphql::Data::default();
870 data.insert(identity);
871 Ok(data)
872 }
873 Err(refusal) => Err(async_graphql::Error::new(
874 refusal
875 .pointer("/errors/0/message")
876 .and_then(serde_json::Value::as_str)
877 .unwrap_or("connection refused"),
878 )),
879 }
880 })
881 .serve()
882 .await
883 })
884 }
885
886 // Add GraphQL routes with WebSocket support for subscriptions
887 let graphql_router = Router::new()
888 .route("/", post(handle_graphql))
889 .route("/", get(postrust_graphql::handler::graphql_playground))
890 .with_state(graphql_app_state.clone());
891
892 // The same state as HTTP, because a WebSocket is authenticated the
893 // same way: `postrust_graphql::handler::graphql_ws_handler` carries
894 // no caller identity, so every subscription it served ran without
895 // a role, a session or claims.
896 let ws_router = Router::new()
897 .route("/ws", get(graphql_ws))
898 .with_state(graphql_app_state);
899
900 let graphql_app = match host.websockets {
901 true => graphql_router.merge(ws_router),
902 false => graphql_router,
903 };
904 // `/v1/graphql` is where a Hasura client sends its queries, and it is
905 // the only address most of them can be told about: the endpoint is
906 // baked into generated clients and codegen configs. `/api/graphql`
907 // keeps working for anything already pointed at it, and
908 // `/v1alpha1/graphql` is the address Hasura served before `/v1`
909 // and still answers on -- a client old enough to have been
910 // pointed there is exactly the one that cannot be repointed.
911 app = app
912 .nest("/v1/graphql", graphql_app.clone())
913 .nest("/v1alpha1/graphql", graphql_app.clone())
914 .nest("/api/graphql", graphql_app);
915 }
916 }
917
918 // PostgREST compatibility mode: also serve the REST surface at the root so
919 // canonical PostgREST paths (`/rpc/<name>`, `/<table>`) work in addition to
920 // the `/api`-prefixed paths. Explicit routes (`/`, `/_`, `/admin`, `/api`)
921 // still take precedence; only otherwise-unmatched paths hit this fallback.
922 if config.compat_mode {
923 app = app.fallback(handle_request);
924 info!("PostgREST compatibility mode enabled: REST surface also served at /");
925
926 // Key ordering is fixed when the binary is compiled, so asking for
927 // compatibility at runtime cannot turn it on. Say so, rather than
928 // leaving someone to discover the difference by diffing responses.
929 if !cfg!(feature = "compat-key-order") {
930 tracing::warn!(
931 "compatibility mode: object keys will be returned in alphabetical order, \
932 not in select order as PostgREST returns them. Build with \
933 --features compat-key-order to match, at a cost of up to 15% throughput \
934 on wide rows."
935 );
936 }
937 }
938
939 // Add root info endpoint.
940 //
941 // Not in compatibility mode: there `/` is part of the API surface -- it is
942 // where PostgREST serves the schema description, and where it reports an
943 // `Accept-Profile` naming a schema that is not exposed. A directory of
944 // this server's own endpoints in its place answers a different question
945 // from the one asked.
946 if !config.compat_mode {
947 app = app.route(
948 "/",
949 axum::routing::get(|| async {
950 Json(serde_json::json!({
951 "name": "postrust",
952 "version": env!("CARGO_PKG_VERSION"),
953 "api": "/api",
954 "custom": "/_",
955 "health": "/_/health",
956 "admin": "/admin",
957 "docs": "/admin/swagger"
958 }))
959 }),
960 );
961 }
962
963 // An empty list is "any origin", which is the default and what
964 // `PGRST_SERVER_CORS_ORIGINS="*"` asks for. Anything that is not a valid
965 // header value is dropped with a warning rather than quietly widening the
966 // policy back to `*` -- a typo in an origin should cost that origin, not
967 // the restriction.
968 let allow_origin = if config.server_cors_origins.is_empty() {
969 AllowOrigin::any()
970 } else {
971 let origins: Vec<HeaderValue> = config
972 .server_cors_origins
973 .iter()
974 .filter_map(|o| match HeaderValue::from_str(o) {
975 Ok(v) => Some(v),
976 Err(_) => {
977 tracing::warn!("Ignoring CORS origin {:?}: not a valid header value", o);
978 None
979 }
980 })
981 .collect();
982 info!("CORS restricted to {} origin(s)", origins.len());
983 AllowOrigin::list(origins)
984 };
985
986 // Apply CORS and state
987 let app = app
988 .layer(
989 CorsLayer::new()
990 .allow_origin(allow_origin)
991 .allow_methods([
992 Method::GET,
993 Method::POST,
994 Method::PUT,
995 Method::PATCH,
996 Method::DELETE,
997 Method::OPTIONS,
998 Method::HEAD,
999 ])
1000 .allow_headers(CorsAny)
1001 .expose_headers(CorsAny),
1002 )
1003 // For the routes whose handlers take a body *extractor* -- GraphQL,
1004 // the admin surface -- which is what this layer reaches. The REST
1005 // handler takes the whole `Request` and reads the body itself, so it
1006 // applies `max_body_size` directly; see `app::process_request`.
1007 .layer(DefaultBodyLimit::max(config.max_body_size))
1008 // Outermost, so it runs before the CORS layer -- which answers every
1009 // OPTIONS itself and never calls what it wraps, so nothing downstream
1010 // of it can say what a resource allows.
1011 .layer(axum::middleware::from_fn_with_state(
1012 state.clone(),
1013 app::options_allow,
1014 ))
1015 .with_state(state.clone());
1016
1017 Ok(App { router: app, state })
1018}
1019
1020/// Reload the REST schema cache on `NOTIFY <channel>`.
1021///
1022/// Reconnects on its own: a listening connection is dropped by a restart of
1023/// the database or anything between, and a reloader that gives up on the first
1024/// error is one that stops working silently partway through a deployment.
1025fn spawn_schema_reloader(state: Arc<AppState>, channel: String) {
1026 const RETRY: std::time::Duration = std::time::Duration::from_secs(5);
1027
1028 tokio::spawn(async move {
1029 loop {
1030 let mut listener = match sqlx::postgres::PgListener::connect_with(&state.pool).await {
1031 Ok(listener) => listener,
1032 Err(e) => {
1033 error!("Schema reloader could not connect: {}; retrying", e);
1034 tokio::time::sleep(RETRY).await;
1035 continue;
1036 }
1037 };
1038
1039 if let Err(e) = listener.listen(&channel).await {
1040 error!("Schema reloader could not LISTEN on {}: {}", channel, e);
1041 tokio::time::sleep(RETRY).await;
1042 continue;
1043 }
1044 info!("Listening on channel {} for schema reloads", channel);
1045
1046 // Inner loop until the connection fails, then reconnect.
1047 loop {
1048 match listener.recv().await {
1049 Ok(notification) => {
1050 info!("Schema reload requested: {:?}", notification.payload());
1051 match state.reload_schema().await {
1052 Ok(()) => {
1053 info!(
1054 "Schema cache reloaded: {}",
1055 state.schema_cache().await.summary()
1056 )
1057 }
1058 // The old cache is kept. Serving the previous
1059 // schema is better than serving none, and the next
1060 // notification tries again.
1061 Err(e) => error!("Schema cache reload failed: {}", e),
1062 }
1063 }
1064 Err(e) => {
1065 error!("Schema reloader lost its connection: {}; reconnecting", e);
1066 break;
1067 }
1068 }
1069 }
1070 }
1071 });
1072}
1073
1074/// Mask database URI for logging.
1075fn mask_db_uri(uri: &str) -> String {
1076 if let Some(at_pos) = uri.find('@') {
1077 if let Some(proto_end) = uri.find("://") {
1078 return format!("{}://***@{}", &uri[..proto_end], &uri[at_pos + 1..]);
1079 }
1080 }
1081 uri.to_string()
1082}