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 `SET LOCAL` statements this request's session variables need.
182    ///
183    /// The setting name is built from the variable's own name after it has
184    /// been checked, and the value is bound rather than interpolated:
185    /// `set_config` takes both as arguments, where `SET LOCAL` would need the
186    /// value written into the statement.
187    pub fn session_settings(&self) -> Vec<(String, String)> {
188        session_settings_for(&self.session, self.acting_role())
189    }
190
191    /// The session as a function reading `hasura_session` expects it.
192    ///
193    /// Hasura hands a function a JSON object of the caller's session
194    /// variables, under the names they arrive as -- `x-hasura-role`,
195    /// `x-hasura-user-id` -- which is what a function body indexes into.
196    /// [`Self::session`] holds them stripped and lowercased for the settings a
197    /// row-level policy reads, so the prefix goes back on here.
198    ///
199    /// The role is included and is not the caller's to choose: it is what
200    /// authenticated the request, or the role an authenticated administrator
201    /// asked to be treated as -- never a bare header.
202    pub fn hasura_session(&self) -> serde_json::Value {
203        hasura_session_document(&self.session, self.acting_role())
204    }
205
206    /// The database role this request's transaction runs as.
207    ///
208    /// Not [`Self::acting_role`], which is who the caller says it is. `SET
209    /// LOCAL ROLE` takes this one, because it is the only one of the two that
210    /// has to exist in the catalogue.
211    pub fn role(&self) -> &str {
212        &self.auth.role
213    }
214
215    /// Get a claim value.
216    pub fn claim(&self, key: &str) -> Option<&serde_json::Value> {
217        self.auth.claims.get(key)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn create_test_auth() -> AuthResult {
226        let mut claims = HashMap::new();
227        claims.insert("user_id".into(), serde_json::json!(123));
228        claims.insert("role".into(), serde_json::json!("admin"));
229
230        AuthResult {
231            role: "authenticated".into(),
232            claims,
233        }
234    }
235
236    #[test]
237    fn test_context_role() {
238        let auth = create_test_auth();
239        // Note: We can't fully test without a pool, but we can test the auth part
240        assert_eq!(auth.role, "authenticated");
241    }
242
243    #[test]
244    fn test_context_claim() {
245        let auth = create_test_auth();
246        assert_eq!(auth.claims.get("user_id"), Some(&serde_json::json!(123)));
247    }
248
249    fn session(pairs: &[(&str, &str)]) -> HashMap<String, String> {
250        pairs
251            .iter()
252            .map(|(name, value)| (name.to_string(), value.to_string()))
253            .collect()
254    }
255
256    fn setting<'a>(settings: &'a [(String, String)], name: &str) -> Option<&'a str> {
257        settings
258            .iter()
259            .find(|(key, _)| key == name)
260            .map(|(_, value)| value.as_str())
261    }
262
263    #[test]
264    fn the_session_document_says_which_role_is_asking() {
265        let document = hasura_session_document(&session(&[("user_id", "1")]), "user");
266        assert_eq!(document["x-hasura-role"], serde_json::json!("user"));
267        assert_eq!(document["x-hasura-user-id"], serde_json::json!("1"));
268    }
269
270    #[test]
271    fn a_session_name_gets_its_dashes_back_in_the_document() {
272        // `SET LOCAL` cannot carry a dash in a setting name, so the variables
273        // are held with underscores -- and a function body indexes into the
274        // document by the name the header arrived as.
275        let document = hasura_session_document(&session(&[("infant_name", "Bittu")]), "infant");
276        assert_eq!(document["x-hasura-infant-name"], serde_json::json!("Bittu"));
277    }
278
279    #[test]
280    fn the_role_is_a_setting_of_its_own_beside_the_document() {
281        let settings = session_settings_for(&session(&[("user_id", "1")]), "Artist");
282        assert_eq!(setting(&settings, "hasura.role"), Some("Artist"));
283        assert_eq!(setting(&settings, "hasura.user_id"), Some("1"));
284        // And the document, for a function that takes the whole thing.
285        let document: serde_json::Value =
286            serde_json::from_str(setting(&settings, "hasura.session").unwrap()).unwrap();
287        assert_eq!(document["x-hasura-role"], serde_json::json!("Artist"));
288    }
289
290    #[test]
291    fn a_name_that_could_not_be_a_setting_is_dropped_rather_than_written() {
292        // The name goes into the statement and the value is bound, so a name
293        // that is not an identifier is the one thing here that could be an
294        // injection. It never reaches SQL.
295        let settings = session_settings_for(
296            &session(&[("user_id", "1"), ("bad'; drop table t; --", "x")]),
297            "user",
298        );
299        assert!(setting(&settings, "hasura.user_id").is_some());
300        assert!(settings
301            .iter()
302            .all(|(name, _)| !name.contains("drop table")));
303    }
304}