Skip to main content

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}