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}