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}