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 sqlx::PgPool;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9/// The transaction every write in one operation shares.
10///
11/// A GraphQL mutation may name several root fields, and the specification
12/// resolves them one after another. Hasura runs the whole set in one
13/// transaction, so a mutation whose second root field violates a constraint
14/// leaves nothing behind from its first -- which is what a client sending
15/// "create the order and its lines" is relying on. Opening a transaction per
16/// resolver, as this used to, half-applies that mutation and reports failure.
17///
18/// Opened lazily by the first write and settled once the operation is answered:
19/// committed when the response carries no errors, rolled back otherwise. A
20/// query never touches it.
21pub type SharedWrite = Arc<tokio::sync::Mutex<Option<sqlx::Transaction<'static, sqlx::Postgres>>>>;
22
23/// The `SET LOCAL` settings a request's session variables and role need.
24///
25/// Free of the context so it can be read on its own, which is also the only
26/// way to test it: a [`GraphQLContext`] needs a connection pool and none of
27/// this does.
28///
29/// The setting name is built from the variable's own name after it has been
30/// checked, and the value is bound rather than interpolated: `set_config`
31/// takes both as arguments, where `SET LOCAL` would need the value written
32/// into the statement.
33pub fn session_settings_for(
34    session: &HashMap<String, String>,
35    role: &str,
36) -> Vec<(String, String)> {
37    let mut settings: Vec<(String, String)> = session
38        .iter()
39        .filter(|(name, _)| {
40            !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
41        })
42        .map(|(name, value)| (format!("hasura.{}", name), value.clone()))
43        .collect();
44    // The whole session as one document, for a function that takes it.
45    // A setting rather than a bound parameter because the places that call
46    // such a function are projections and correlated subselects, which
47    // assemble SQL without a parameter list to add to -- and because it is
48    // then set exactly where the role is, once per transaction.
49    settings.push((
50        "hasura.session".to_string(),
51        hasura_session_document(session, role).to_string(),
52    ));
53    // Beside the document, the role on its own, because a policy asking who is
54    // calling should not have to parse JSON to find out.
55    settings.push(("hasura.role".to_string(), role.to_string()));
56    settings
57}
58
59/// The session as a function reading `hasura_session` expects it.
60///
61/// Hasura hands a function a JSON object of the caller's session variables
62/// under the names they arrive as -- `x-hasura-role`, `x-hasura-user-id` --
63/// which is what a function body indexes into. They are held stripped and
64/// lowercased for the settings a row-level policy reads, so the prefix goes
65/// back on here.
66pub fn hasura_session_document(session: &HashMap<String, String>, role: &str) -> serde_json::Value {
67    let mut object = serde_json::Map::new();
68    for (name, value) in session {
69        object.insert(
70            format!("x-hasura-{}", name.replace('_', "-")),
71            serde_json::Value::String(value.clone()),
72        );
73    }
74    object.insert(
75        "x-hasura-role".to_string(),
76        serde_json::Value::String(role.to_string()),
77    );
78    serde_json::Value::Object(object)
79}
80
81/// Context available to all GraphQL resolvers.
82pub struct GraphQLContext {
83    /// Database connection pool.
84    pub pool: PgPool,
85    /// Schema cache for table/column metadata.
86    pub schema_cache: SchemaCacheRef,
87    /// Authentication result with role and claims.
88    pub auth: AuthResult,
89    /// Session variables carried into the transaction, without their
90    /// `x-hasura-` prefix and lowercased: `user_id`, `org_id`.
91    ///
92    /// This is the half of Hasura's permission model that transfers. Its rules
93    /// live in metadata and are compiled into every query; here permissions
94    /// live in the database as roles and row level security, and what a policy
95    /// needs is the caller's identity. A policy written against
96    /// `current_setting('hasura.user_id')` sees what the Hasura permission
97    /// would have seen.
98    pub session: HashMap<String, String>,
99    /// The role the caller speaks as, in Hasura's sense, where that is not the
100    /// database role the transaction runs as.
101    ///
102    /// The two are different things and conflating them was the reason
103    /// `hasura_session->>'x-hasura-role'` used to answer with the name of the
104    /// connecting database user. Hasura's roles are labels -- `Artist`,
105    /// `anonymous` -- that exist in no catalogue and could not be `SET ROLE`
106    /// to; what they decide is which permissions apply and what a function
107    /// reading the session document sees. Which database user the transaction
108    /// runs as is [`AuthResult::role`], and it is settled separately.
109    ///
110    /// `None` when nothing named a Hasura role, in which case the database
111    /// role stands in for it, as it did before the two were told apart.
112    pub hasura_role: Option<String>,
113    /// Whether the admin secret authenticated this request.
114    ///
115    /// Not the same question as whether the role is `admin`, which an
116    /// administrator gives up as soon as it asks to be treated as someone
117    /// else. What this gates is the fields a `backend_only` permission hides:
118    /// reachable by a caller that proved it holds the secret, whatever role it
119    /// then claims, and by no one else.
120    pub elevated: bool,
121    /// The transaction every write in this operation shares. See [`SharedWrite`].
122    pub write: SharedWrite,
123}
124
125impl GraphQLContext {
126    /// Create a new GraphQL context.
127    pub fn new(pool: PgPool, schema_cache: SchemaCacheRef, auth: AuthResult) -> Self {
128        Self {
129            pool,
130            schema_cache,
131            auth,
132            session: HashMap::new(),
133            hasura_role: None,
134            elevated: false,
135            write: Arc::new(tokio::sync::Mutex::new(None)),
136        }
137    }
138
139    /// Record who the caller is in Hasura's sense, and whether it proved it.
140    pub fn with_identity(mut self, role: Option<String>, elevated: bool) -> Self {
141        self.hasura_role = role;
142        self.elevated = elevated;
143        self
144    }
145
146    /// The role the caller speaks as: the Hasura role where one was named, and
147    /// the database role otherwise.
148    pub fn acting_role(&self) -> &str {
149        self.hasura_role.as_deref().unwrap_or(&self.auth.role)
150    }
151
152    /// Who is asking, for the SQL builders that have to narrow rows to them.
153    ///
154    /// The Hasura role rather than [`Self::acting_role`]: a permission is
155    /// keyed by a role someone wrote into a document, and the database role
156    /// standing in for a missing one would name no permission and read as
157    /// unrestricted -- which is right, and is worth being explicit about.
158    pub fn caller(&self) -> crate::role::Caller<'_> {
159        crate::role::Caller {
160            role: self.hasura_role.as_deref(),
161            session: &self.session,
162        }
163    }
164
165    /// Share one transaction with whoever is going to settle it.
166    ///
167    /// The caller keeps the other handle: it is the only thing that knows
168    /// whether the operation ended in errors, and so the only thing that can
169    /// decide between commit and rollback.
170    pub fn with_write(mut self, write: SharedWrite) -> Self {
171        self.write = write;
172        self
173    }
174
175    /// Carry session variables into every transaction this request opens.
176    pub fn with_session(mut self, session: HashMap<String, String>) -> Self {
177        self.session = session;
178        self
179    }
180
181    /// The transaction-local settings this request's identity needs.
182    ///
183    /// Hasura session variables retain their existing representation, while
184    /// the verified JWT is exposed as both `request.jwt.claims` JSON and
185    /// individual `request.jwt.claims.<name>` settings.
186    pub fn session_settings(&self) -> Vec<(String, String)> {
187        let mut settings = session_settings_for(&self.session, self.acting_role());
188        settings.extend(self.auth.claim_settings());
189        settings
190    }
191
192    /// The session as a function reading `hasura_session` expects it.
193    ///
194    /// Hasura hands a function a JSON object of the caller's session
195    /// variables, under the names they arrive as -- `x-hasura-role`,
196    /// `x-hasura-user-id` -- which is what a function body indexes into.
197    /// [`Self::session`] holds them stripped and lowercased for the settings a
198    /// row-level policy reads, so the prefix goes back on here.
199    ///
200    /// The role is included and is not the caller's to choose: it is what
201    /// authenticated the request, or the role an authenticated administrator
202    /// asked to be treated as -- never a bare header.
203    pub fn hasura_session(&self) -> serde_json::Value {
204        hasura_session_document(&self.session, self.acting_role())
205    }
206
207    /// The database role this request's transaction runs as.
208    ///
209    /// Not [`Self::acting_role`], which is who the caller says it is. `SET
210    /// LOCAL ROLE` takes this one, because it is the only one of the two that
211    /// has to exist in the catalogue.
212    pub fn role(&self) -> &str {
213        &self.auth.role
214    }
215
216    /// Get a claim value.
217    pub fn claim(&self, key: &str) -> Option<&serde_json::Value> {
218        self.auth.claims.get(key)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn create_test_auth() -> AuthResult {
227        let mut claims = HashMap::new();
228        claims.insert("user_id".into(), serde_json::json!(123));
229        claims.insert("role".into(), serde_json::json!("admin"));
230
231        AuthResult {
232            role: "authenticated".into(),
233            claims,
234        }
235    }
236
237    #[test]
238    fn test_context_role() {
239        let auth = create_test_auth();
240        // Note: We can't fully test without a pool, but we can test the auth part
241        assert_eq!(auth.role, "authenticated");
242    }
243
244    #[test]
245    fn test_context_claim() {
246        let auth = create_test_auth();
247        assert_eq!(auth.claims.get("user_id"), Some(&serde_json::json!(123)));
248    }
249
250    fn session(pairs: &[(&str, &str)]) -> HashMap<String, String> {
251        pairs
252            .iter()
253            .map(|(name, value)| (name.to_string(), value.to_string()))
254            .collect()
255    }
256
257    fn setting<'a>(settings: &'a [(String, String)], name: &str) -> Option<&'a str> {
258        settings
259            .iter()
260            .find(|(key, _)| key == name)
261            .map(|(_, value)| value.as_str())
262    }
263
264    #[test]
265    fn the_session_document_says_which_role_is_asking() {
266        let document = hasura_session_document(&session(&[("user_id", "1")]), "user");
267        assert_eq!(document["x-hasura-role"], serde_json::json!("user"));
268        assert_eq!(document["x-hasura-user-id"], serde_json::json!("1"));
269    }
270
271    #[test]
272    fn a_session_name_gets_its_dashes_back_in_the_document() {
273        // `SET LOCAL` cannot carry a dash in a setting name, so the variables
274        // are held with underscores -- and a function body indexes into the
275        // document by the name the header arrived as.
276        let document = hasura_session_document(&session(&[("infant_name", "Bittu")]), "infant");
277        assert_eq!(document["x-hasura-infant-name"], serde_json::json!("Bittu"));
278    }
279
280    #[test]
281    fn the_role_is_a_setting_of_its_own_beside_the_document() {
282        let settings = session_settings_for(&session(&[("user_id", "1")]), "Artist");
283        assert_eq!(setting(&settings, "hasura.role"), Some("Artist"));
284        assert_eq!(setting(&settings, "hasura.user_id"), Some("1"));
285        // And the document, for a function that takes the whole thing.
286        let document: serde_json::Value =
287            serde_json::from_str(setting(&settings, "hasura.session").unwrap()).unwrap();
288        assert_eq!(document["x-hasura-role"], serde_json::json!("Artist"));
289    }
290
291    #[tokio::test]
292    async fn graphql_transactions_receive_the_verified_jwt_claims_document() {
293        let ctx = GraphQLContext::new(
294            PgPool::connect_lazy("postgres://localhost/test").expect("valid test URL"),
295            SchemaCacheRef::default(),
296            create_test_auth(),
297        );
298
299        let settings = ctx.session_settings();
300        let claims: serde_json::Value =
301            serde_json::from_str(setting(&settings, "request.jwt.claims").unwrap()).unwrap();
302
303        assert_eq!(claims["user_id"], serde_json::json!(123));
304        assert_eq!(claims["role"], serde_json::json!("authenticated"));
305        assert_eq!(
306            setting(&settings, "request.jwt.claims.user_id"),
307            Some("123")
308        );
309        assert_eq!(
310            setting(&settings, "request.jwt.claims.role"),
311            Some("authenticated")
312        );
313    }
314
315    #[test]
316    fn a_name_that_could_not_be_a_setting_is_dropped_rather_than_written() {
317        // The name goes into the statement and the value is bound, so a name
318        // that is not an identifier is the one thing here that could be an
319        // injection. It never reaches SQL.
320        let settings = session_settings_for(
321            &session(&[("user_id", "1"), ("bad'; drop table t; --", "x")]),
322            "user",
323        );
324        assert!(setting(&settings, "hasura.user_id").is_some());
325        assert!(settings
326            .iter()
327            .all(|(name, _)| !name.contains("drop table")));
328    }
329}