postrust_graphql/context.rs
1//! GraphQL context containing auth, database pool, and schema cache.
2
3use postrust_auth::AuthResult;
4use postrust_core::schema_cache::SchemaCacheRef;
5use postrust_core::{AppConfig, Session};
6use sqlx::PgPool;
7use std::collections::HashMap;
8use std::sync::Arc;
9
10/// The transaction every write in one operation shares.
11///
12/// A GraphQL mutation may name several root fields, and the specification
13/// resolves them one after another. Hasura runs the whole set in one
14/// transaction, so a mutation whose second root field violates a constraint
15/// leaves nothing behind from its first -- which is what a client sending
16/// "create the order and its lines" is relying on. Opening a transaction per
17/// resolver, as this used to, half-applies that mutation and reports failure.
18///
19/// Opened lazily by the first write and settled once the operation is answered:
20/// committed when the response carries no errors, rolled back otherwise. A
21/// query never touches it.
22pub type SharedWrite = Arc<tokio::sync::Mutex<Option<sqlx::Transaction<'static, sqlx::Postgres>>>>;
23
24/// The `hasura.*` settings a request's session variables and role need.
25///
26/// Free of the context so it can be read on its own, which is also the only
27/// way to test it: a [`GraphQLContext`] needs a connection pool and none of
28/// this does.
29///
30/// The setting name is built from the variable's own name after it has been
31/// checked, and the value is bound rather than interpolated: `set_config`
32/// takes both as arguments, where `SET LOCAL` would need the value written
33/// into the statement.
34pub fn session_settings_for(
35 session: &HashMap<String, String>,
36 role: &str,
37) -> Vec<(String, String)> {
38 let mut settings: Vec<(String, String)> = session
39 .iter()
40 .filter(|(name, _)| {
41 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
42 })
43 .map(|(name, value)| (format!("hasura.{}", name), value.clone()))
44 .collect();
45 // The whole session as one document, for a function that takes it.
46 // A setting rather than a bound parameter because the places that call
47 // such a function are projections and correlated subselects, which
48 // assemble SQL without a parameter list to add to -- and because it is
49 // then set exactly where the role is, once per transaction.
50 settings.push((
51 "hasura.session".to_string(),
52 hasura_session_document(session, role).to_string(),
53 ));
54 // Beside the document, the role on its own, because a policy asking who is
55 // calling should not have to parse JSON to find out.
56 settings.push(("hasura.role".to_string(), role.to_string()));
57 settings
58}
59
60/// The session as a function reading `hasura_session` expects it.
61///
62/// Hasura hands a function a JSON object of the caller's session variables
63/// under the names they arrive as -- `x-hasura-role`, `x-hasura-user-id` --
64/// which is what a function body indexes into. They are held stripped and
65/// lowercased for the settings a row-level policy reads, so the prefix goes
66/// back on here.
67pub fn hasura_session_document(session: &HashMap<String, String>, role: &str) -> serde_json::Value {
68 let mut object = serde_json::Map::new();
69 for (name, value) in session {
70 object.insert(
71 format!("x-hasura-{}", name.replace('_', "-")),
72 serde_json::Value::String(value.clone()),
73 );
74 }
75 object.insert(
76 "x-hasura-role".to_string(),
77 serde_json::Value::String(role.to_string()),
78 );
79 serde_json::Value::Object(object)
80}
81
82/// Context available to all GraphQL resolvers.
83pub struct GraphQLContext {
84 /// Database connection pool.
85 pub pool: PgPool,
86 /// Schema cache for table/column metadata.
87 pub schema_cache: SchemaCacheRef,
88 /// Authentication result with role and claims.
89 pub auth: AuthResult,
90 /// Session variables carried into the transaction, without their
91 /// `x-hasura-` prefix and lowercased: `user_id`, `org_id`.
92 ///
93 /// This is the half of Hasura's permission model that transfers. Its rules
94 /// live in metadata and are compiled into every query; here permissions
95 /// live in the database as roles and row level security, and what a policy
96 /// needs is the caller's identity. A policy written against
97 /// `current_setting('hasura.user_id')` sees what the Hasura permission
98 /// would have seen.
99 pub session: HashMap<String, String>,
100 /// The role the caller speaks as, in Hasura's sense, where that is not the
101 /// database role the transaction runs as.
102 ///
103 /// The two are different things and conflating them was the reason
104 /// `hasura_session->>'x-hasura-role'` used to answer with the name of the
105 /// connecting database user. Hasura's roles are labels -- `Artist`,
106 /// `anonymous` -- that exist in no catalogue and could not be `SET ROLE`
107 /// to; what they decide is which permissions apply and what a function
108 /// reading the session document sees. Which database user the transaction
109 /// runs as is [`AuthResult::role`], and it is settled separately.
110 ///
111 /// `None` when nothing named a Hasura role, in which case the database
112 /// role stands in for it, as it did before the two were told apart.
113 pub hasura_role: Option<String>,
114 /// Whether the admin secret authenticated this request.
115 ///
116 /// Not the same question as whether the role is `admin`, which an
117 /// administrator gives up as soon as it asks to be treated as someone
118 /// else. What this gates is the fields a `backend_only` permission hides:
119 /// reachable by a caller that proved it holds the secret, whatever role it
120 /// then claims, and by no one else.
121 pub elevated: bool,
122 /// The transaction every write in this operation shares. See [`SharedWrite`].
123 pub write: SharedWrite,
124 /// The server's configuration, which a transaction is set up from.
125 ///
126 /// What [`Self::session`] reads beyond the caller: a role's own `statement_timeout` and isolation level, the server-wide
127 /// isolation level and `app_settings`. The same configuration REST's
128 /// transactions are set up from, so a function or a policy sees the same
129 /// thing whichever of the two called it.
130 pub config: Arc<AppConfig>,
131}
132
133impl GraphQLContext {
134 /// Create a new GraphQL context.
135 pub fn new(pool: PgPool, schema_cache: SchemaCacheRef, auth: AuthResult) -> Self {
136 Self {
137 pool,
138 schema_cache,
139 auth,
140 session: HashMap::new(),
141 hasura_role: None,
142 elevated: false,
143 write: Arc::new(tokio::sync::Mutex::new(None)),
144 config: Arc::new(AppConfig::default()),
145 }
146 }
147
148 /// Set up this request's transactions from `config`.
149 ///
150 /// Without it they are set up from [`AppConfig::default`]: PostgREST's
151 /// defaults, and no role settings.
152 pub fn with_config(mut self, config: Arc<AppConfig>) -> Self {
153 self.config = config;
154 self
155 }
156
157 /// Record who the caller is in Hasura's sense, and whether it proved it.
158 pub fn with_identity(mut self, role: Option<String>, elevated: bool) -> Self {
159 self.hasura_role = role;
160 self.elevated = elevated;
161 self
162 }
163
164 /// The role the caller speaks as: the Hasura role where one was named, and
165 /// the database role otherwise.
166 pub fn acting_role(&self) -> &str {
167 self.hasura_role.as_deref().unwrap_or(&self.auth.role)
168 }
169
170 /// Who is asking, for the SQL builders that have to narrow rows to them.
171 ///
172 /// The Hasura role rather than [`Self::acting_role`]: a permission is
173 /// keyed by a role someone wrote into a document, and the database role
174 /// standing in for a missing one would name no permission and read as
175 /// unrestricted -- which is right, and is worth being explicit about.
176 pub fn caller(&self) -> crate::role::Caller<'_> {
177 crate::role::Caller {
178 role: self.hasura_role.as_deref(),
179 session: &self.session,
180 }
181 }
182
183 /// Share one transaction with whoever is going to settle it.
184 ///
185 /// The caller keeps the other handle: it is the only thing that knows
186 /// whether the operation ended in errors, and so the only thing that can
187 /// decide between commit and rollback.
188 pub fn with_write(mut self, write: SharedWrite) -> Self {
189 self.write = write;
190 self
191 }
192
193 /// Carry session variables into every transaction this request opens.
194 pub fn with_session(mut self, session: HashMap<String, String>) -> Self {
195 self.session = session;
196 self
197 }
198
199 /// What makes a transaction this request's: the statements that turn a
200 /// pooled connection into the caller.
201 ///
202 /// The same [`Session`] a REST request's transaction is set up by, so the
203 /// two cannot drift apart: one `SELECT set_config(...)` carrying the
204 /// role's own `statement_timeout`, the database role,
205 /// the verified JWT as `request.jwt.claims` and `request.jwt.claims.<name>`,
206 /// and the `app.settings.*` -- and then, last, the `hasura.*` session
207 /// variables, which no REST request has. Ahead of it, the isolation level
208 /// where the role's setting or `PGRST_DB_TX_ISOLATION` asks for one other
209 /// than PostgreSQL's default.
210 ///
211 /// The role is [`Self::role`], the database role, never the Hasura role
212 /// a caller speaks as -- which exists in no catalogue and goes in only as
213 /// `hasura.role` and inside `hasura.session`.
214 ///
215 /// Unlike REST, the `search_path` is left as the role and the database
216 /// set it, as GraphQL has always left it and as Hasura does. An operation
217 /// may touch every exposed schema, and the SQL generated for one names
218 /// each table it reads with its schema, so the path decides nothing about
219 /// which table a field reads. What it does decide is where the
220 /// unqualified calls in that SQL -- PostGIS's `ST_AsGeoJSON` among them
221 /// -- are found. Setting REST's path would stop finding an extension
222 /// installed outside `db_extra_search_path` (in `extensions`, say, as
223 /// Supabase installs them) that the role's own path finds, and a GraphQL
224 /// query that worked would stop working.
225 ///
226 /// Nothing about the HTTP request goes in. `request.headers` would carry
227 /// the admin secret, which [`postrust_auth::hasura`] keeps out of every
228 /// setting a function can read; `request.method` and `request.path` are
229 /// the same for every operation and tell a function nothing. And
230 /// `db_pre_request` is not called: it is a hook on REST requests, written
231 /// against the `request.*` settings it would find missing here, and GraphQL
232 /// has never run it.
233 ///
234 /// Read-write, as a GraphQL transaction always has been, for a query as
235 /// for a mutation. A query's SQL is a `SELECT`, and the query root serves
236 /// no volatile function, so `SET TRANSACTION READ ONLY` would refuse
237 /// little -- but whatever it did refuse, a volatile function a computed
238 /// field calls say, is an answer that changes, and that is a change of its
239 /// own to make and measure rather than one to carry in with this.
240 pub fn session(&self) -> Session<'_> {
241 session_settings_for(&self.session, self.acting_role())
242 .into_iter()
243 .fold(
244 Session::new(&self.config, self.role(), self.auth.claim_settings())
245 .pre_request(false)
246 .set_search_path(false),
247 |session, (name, value)| session.setting(name, value),
248 )
249 }
250
251 /// The session as a function reading `hasura_session` expects it.
252 ///
253 /// Hasura hands a function a JSON object of the caller's session
254 /// variables, under the names they arrive as -- `x-hasura-role`,
255 /// `x-hasura-user-id` -- which is what a function body indexes into.
256 /// [`Self::session`] holds them stripped and lowercased for the settings a
257 /// row-level policy reads, so the prefix goes back on here.
258 ///
259 /// The role is included and is not the caller's to choose: it is what
260 /// authenticated the request, or the role an authenticated administrator
261 /// asked to be treated as -- never a bare header.
262 pub fn hasura_session(&self) -> serde_json::Value {
263 hasura_session_document(&self.session, self.acting_role())
264 }
265
266 /// The database role this request's transaction runs as.
267 ///
268 /// Not [`Self::acting_role`], which is who the caller says it is. The
269 /// session's `role` setting takes this one, because it is the only one of
270 /// the two that has to exist in the catalogue.
271 pub fn role(&self) -> &str {
272 &self.auth.role
273 }
274
275 /// Get a claim value.
276 pub fn claim(&self, key: &str) -> Option<&serde_json::Value> {
277 self.auth.claims.get(key)
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn create_test_auth() -> AuthResult {
286 let mut claims = HashMap::new();
287 claims.insert("user_id".into(), serde_json::json!(123));
288 claims.insert("role".into(), serde_json::json!("admin"));
289
290 AuthResult {
291 role: "authenticated".into(),
292 claims,
293 }
294 }
295
296 #[test]
297 fn test_context_role() {
298 let auth = create_test_auth();
299 // Note: We can't fully test without a pool, but we can test the auth part
300 assert_eq!(auth.role, "authenticated");
301 }
302
303 #[test]
304 fn test_context_claim() {
305 let auth = create_test_auth();
306 assert_eq!(auth.claims.get("user_id"), Some(&serde_json::json!(123)));
307 }
308
309 fn session(pairs: &[(&str, &str)]) -> HashMap<String, String> {
310 pairs
311 .iter()
312 .map(|(name, value)| (name.to_string(), value.to_string()))
313 .collect()
314 }
315
316 fn setting<'a>(settings: &'a [(String, String)], name: &str) -> Option<&'a str> {
317 settings
318 .iter()
319 .find(|(key, _)| key == name)
320 .map(|(_, value)| value.as_str())
321 }
322
323 #[test]
324 fn the_session_document_says_which_role_is_asking() {
325 let document = hasura_session_document(&session(&[("user_id", "1")]), "user");
326 assert_eq!(document["x-hasura-role"], serde_json::json!("user"));
327 assert_eq!(document["x-hasura-user-id"], serde_json::json!("1"));
328 }
329
330 #[test]
331 fn a_session_name_gets_its_dashes_back_in_the_document() {
332 // `SET LOCAL` cannot carry a dash in a setting name, so the variables
333 // are held with underscores -- and a function body indexes into the
334 // document by the name the header arrived as.
335 let document = hasura_session_document(&session(&[("infant_name", "Bittu")]), "infant");
336 assert_eq!(document["x-hasura-infant-name"], serde_json::json!("Bittu"));
337 }
338
339 #[test]
340 fn the_role_is_a_setting_of_its_own_beside_the_document() {
341 let settings = session_settings_for(&session(&[("user_id", "1")]), "Artist");
342 assert_eq!(setting(&settings, "hasura.role"), Some("Artist"));
343 assert_eq!(setting(&settings, "hasura.user_id"), Some("1"));
344 // And the document, for a function that takes the whole thing.
345 let document: serde_json::Value =
346 serde_json::from_str(setting(&settings, "hasura.session").unwrap()).unwrap();
347 assert_eq!(document["x-hasura-role"], serde_json::json!("Artist"));
348 }
349
350 fn context(auth: AuthResult) -> GraphQLContext {
351 GraphQLContext::new(
352 PgPool::connect_lazy("postgres://localhost/test").expect("valid test URL"),
353 SchemaCacheRef::default(),
354 auth,
355 )
356 }
357
358 /// Every setting a session sends, in the order it sends them, from the
359 /// one `SELECT set_config(...)` that carries them.
360 fn sent_settings(ctx: &GraphQLContext) -> Vec<(String, String)> {
361 let statements = ctx.session().statements();
362 let carrying = statements
363 .iter()
364 .find(|s| s.sql.starts_with("SELECT set_config("))
365 .expect("one statement carries the settings");
366 carrying
367 .params
368 .chunks(2)
369 .map(|pair| match pair {
370 [postrust_sql::SqlParam::Text(name), postrust_sql::SqlParam::Text(value)] => {
371 (name.clone(), value.clone())
372 }
373 other => panic!("{other:?}"),
374 })
375 .collect()
376 }
377
378 #[tokio::test]
379 async fn graphql_transactions_receive_the_verified_jwt_claims_document() {
380 let ctx = context(create_test_auth());
381
382 let settings = sent_settings(&ctx);
383 let claims: serde_json::Value =
384 serde_json::from_str(setting(&settings, "request.jwt.claims").unwrap()).unwrap();
385
386 assert_eq!(claims["user_id"], serde_json::json!(123));
387 assert_eq!(claims["role"], serde_json::json!("authenticated"));
388 assert_eq!(
389 setting(&settings, "request.jwt.claims.user_id"),
390 Some("123")
391 );
392 assert_eq!(
393 setting(&settings, "request.jwt.claims.role"),
394 Some("authenticated")
395 );
396 }
397
398 /// The database role is what the transaction runs as, and the Hasura role
399 /// goes in only as `hasura.*`. The role's own settings go before it, the
400 /// `hasura.*` variables after everything REST sends, and nothing about an
401 /// HTTP request goes in at all.
402 #[tokio::test]
403 async fn a_graphql_session_runs_as_the_database_role_with_the_hasura_variables_last() {
404 let mut config = AppConfig::default();
405 config.db_schemas = vec!["api".to_string(), "other".to_string()];
406 config.db_extra_search_path = vec!["public".to_string(), "extensions".to_string()];
407 config.db_pre_request = Some("check_request".to_string());
408 let mut role = postrust_core::config::RoleSettings::default();
409 role.isolation_level = Some(postrust_core::config::IsolationLevel::RepeatableRead);
410 role.statement_timeout = Some(250);
411 config
412 .role_settings
413 .insert("authenticated".to_string(), role);
414 config
415 .app_settings
416 .insert("tenant".to_string(), "acme".to_string());
417 let ctx = context(create_test_auth())
418 .with_config(Arc::new(config))
419 .with_session(session(&[("user_id", "7")]))
420 .with_identity(Some("Artist".to_string()), false);
421
422 let statements = ctx.session().statements();
423 assert_eq!(
424 statements[0].sql, "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ",
425 "the role's isolation level"
426 );
427 assert_eq!(
428 statements.len(),
429 2,
430 "neither read-only nor db_pre_request: {statements:?}"
431 );
432
433 let settings = sent_settings(&ctx);
434 let names: Vec<&str> = settings.iter().map(|(name, _)| name.as_str()).collect();
435 assert_eq!(names[..2], ["statement_timeout", "role"]);
436 assert_eq!(
437 names[names.len() - 3..],
438 ["hasura.user_id", "hasura.session", "hasura.role"]
439 );
440 // The claims and the app settings between them, in whichever order
441 // the claims come.
442 let mut middle = names[2..names.len() - 3].to_vec();
443 middle.sort_unstable();
444 assert_eq!(
445 middle,
446 [
447 "app.settings.tenant",
448 "request.jwt.claims",
449 "request.jwt.claims.role",
450 "request.jwt.claims.user_id",
451 ]
452 );
453 assert_eq!(setting(&settings, "statement_timeout"), Some("250"));
454 // Left as the role and the database set it: see `session`.
455 assert_eq!(setting(&settings, "search_path"), None);
456 assert_eq!(setting(&settings, "role"), Some("authenticated"));
457 assert_eq!(setting(&settings, "hasura.role"), Some("Artist"));
458 }
459
460 #[test]
461 fn a_name_that_could_not_be_a_setting_is_dropped_rather_than_written() {
462 // The name goes into the statement and the value is bound, so a name
463 // that is not an identifier is the one thing here that could be an
464 // injection. It never reaches SQL.
465 let settings = session_settings_for(
466 &session(&[("user_id", "1"), ("bad'; drop table t; --", "x")]),
467 "user",
468 );
469 assert!(setting(&settings, "hasura.user_id").is_some());
470 assert!(settings
471 .iter()
472 .all(|(name, _)| !name.contains("drop table")));
473 }
474}