postrust_core/session.rs
1//! What makes a transaction run as the caller.
2//!
3//! A request does nothing as itself. Its statements run on a pooled
4//! connection logged in as the authenticator, and it is a handful of
5//! statements at the top of the transaction that turn that connection into
6//! the caller's: the role row-level security and grants are checked against,
7//! the claims a policy reads back with `current_setting`, the facts about the
8//! request a function may want, and the configuration's own pre-request
9//! check. Get one of them wrong, or send it outside the transaction, and the
10//! query runs as the authenticator with nothing set -- which is to say with
11//! no security at all, and no error to say so.
12//!
13//! [`Session`] is those statements, worked out once. It knows nothing about
14//! HTTP or about JWTs: the role and the claims come in as plain values, so it
15//! serves a REST request, a GraphQL operation, or a hand-written route that
16//! has a user and no request at all. It runs nothing itself either.
17//! [`Session::statements`] says what would be sent, which is what the tests
18//! look at, and [`Session::apply`] queues it on a [`Tx`].
19
20use crate::config::{AppConfig, IsolationLevel};
21use crate::db::Tx;
22use indexmap::IndexMap;
23use postrust_sql::SqlParam;
24
25/// One statement a [`Session`] sends, with its parameters.
26#[derive(Clone, Debug, PartialEq)]
27pub struct SessionStatement {
28 /// The statement's text.
29 pub sql: String,
30 /// Its parameters, numbered from `$1` in order.
31 pub params: Vec<SqlParam>,
32}
33
34impl SessionStatement {
35 fn new(sql: impl Into<String>, params: Vec<SqlParam>) -> Self {
36 Self {
37 sql: sql.into(),
38 params,
39 }
40 }
41}
42
43/// The statements that make a transaction run as a caller.
44///
45/// Built from the caller's role and claim settings and the configuration;
46/// everything about an HTTP request is optional and added with the builder
47/// methods. A session with no request at all still sets the role, the claims,
48/// the `search_path` -- the default schema and `db_extra_search_path` -- and
49/// the `app.settings.*`, and still runs `db_pre_request`, so a hand-written
50/// route sees what a policy or function expects to see. The request settings
51/// it was not given (`request.method`, `request.path`, `request.headers`,
52/// `request.cookies`) are left unset, which `current_setting(name, true)`
53/// reads as null.
54#[derive(Clone, Debug)]
55pub struct Session<'a> {
56 config: &'a AppConfig,
57 role: String,
58 claims: Vec<(String, String)>,
59 schema: Option<String>,
60 method: Option<String>,
61 path: Option<String>,
62 headers: Option<String>,
63 cookies: Option<String>,
64 trace_id: Option<String>,
65 timezone: Option<String>,
66 extra: Vec<(String, String)>,
67 isolation: Option<IsolationLevel>,
68 read_only: bool,
69 pre_request: bool,
70 set_search_path: bool,
71}
72
73impl<'a> Session<'a> {
74 /// A session for `role`, carrying `claims` -- the `request.jwt.claims*`
75 /// settings, as `postrust_auth::AuthResult::claim_settings` renders them.
76 ///
77 /// Read-write, with `db_pre_request` run if one is configured.
78 pub fn new(
79 config: &'a AppConfig,
80 role: impl Into<String>,
81 claims: Vec<(String, String)>,
82 ) -> Self {
83 Self {
84 config,
85 role: role.into(),
86 claims,
87 schema: None,
88 method: None,
89 path: None,
90 headers: None,
91 cookies: None,
92 trace_id: None,
93 timezone: None,
94 extra: Vec::new(),
95 isolation: None,
96 read_only: false,
97 pre_request: true,
98 set_search_path: true,
99 }
100 }
101
102 /// The schema the request is for, which leads the `search_path`. Without
103 /// one it is the configuration's default schema.
104 pub fn schema(mut self, schema: impl Into<String>) -> Self {
105 self.schema = Some(schema.into());
106 self
107 }
108
109 /// The request's method and path, as `request.method` and `request.path`.
110 pub fn request(mut self, method: impl Into<String>, path: impl Into<String>) -> Self {
111 self.method = Some(method.into());
112 self.path = Some(path.into());
113 self
114 }
115
116 /// The request's headers, as `request.headers`.
117 pub fn headers(mut self, headers: &IndexMap<String, String>) -> Self {
118 self.headers = Some(json_object(headers));
119 self
120 }
121
122 /// The request's cookies, as `request.cookies`.
123 pub fn cookies(mut self, cookies: &IndexMap<String, String>) -> Self {
124 self.cookies = Some(json_object(cookies));
125 self
126 }
127
128 /// The trace the request belongs to, as `request.trace_id`.
129 ///
130 /// So that a statement seen in `pg_stat_activity` or the server log -- by
131 /// `auto_explain`, or a `log_line_prefix` reading the setting -- can be
132 /// tied back to the trace of the request that sent it. Unset without one.
133 pub fn trace_id(mut self, trace_id: Option<String>) -> Self {
134 self.trace_id = trace_id;
135 self
136 }
137
138 /// The session time zone, from `Prefer: timezone`.
139 pub fn timezone(mut self, timezone: Option<String>) -> Self {
140 self.timezone = timezone;
141 self
142 }
143
144 /// One more setting, set after all of the others.
145 ///
146 /// For what a caller other than REST tells the database about itself --
147 /// GraphQL's `hasura.*` session variables, say. The name is bound as a
148 /// parameter like every other, so it is PostgreSQL that decides whether it
149 /// is a name it will take.
150 pub fn setting(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
151 self.extra.push((name.into(), value.into()));
152 self
153 }
154
155 /// The transaction's isolation level, over the role's and the server's.
156 ///
157 /// Without one it is the role's own setting where it has one, and
158 /// `PGRST_DB_TX_ISOLATION` otherwise. For a caller that knows what it
159 /// needs of one transaction -- a hand-written route that reads twice and
160 /// needs both reads to agree. `None` leaves the choice to the
161 /// configuration.
162 pub fn isolation(mut self, isolation: Option<IsolationLevel>) -> Self {
163 self.isolation = isolation;
164 self
165 }
166
167 /// Whether the transaction is read-only. See [`Session::statements`].
168 pub fn read_only(mut self, read_only: bool) -> Self {
169 self.read_only = read_only;
170 self
171 }
172
173 /// Whether to set `search_path` to the schema and `db_extra_search_path`.
174 /// On unless turned off.
175 ///
176 /// Off for SQL that qualifies every name it means to come from the
177 /// API's schemas, and should otherwise resolve names as the role and the
178 /// database normally would -- GraphQL's, which calls extension functions
179 /// such as PostGIS's unqualified, wherever the database installed them.
180 pub fn set_search_path(mut self, set_search_path: bool) -> Self {
181 self.set_search_path = set_search_path;
182 self
183 }
184
185 /// Whether to call `db_pre_request`, where one is configured. On unless
186 /// turned off.
187 pub fn pre_request(mut self, pre_request: bool) -> Self {
188 self.pre_request = pre_request;
189 self
190 }
191
192 /// The role this session runs as.
193 pub fn role(&self) -> &str {
194 &self.role
195 }
196
197 /// Queue this session's statements on `tx`, to go out ahead of its next
198 /// statement -- see [`Tx::queue`]. They have to be the first statements
199 /// on the transaction: `SET TRANSACTION` is refused after any other.
200 pub fn apply<T: Tx>(&self, tx: &mut T) {
201 for statement in self.statements() {
202 tx.queue(&statement.sql, &statement.params);
203 }
204 }
205
206 /// The statements [`Session::apply`] queues, in the order it queues them.
207 ///
208 /// At most four, and usually two: an isolation level, read-only, every
209 /// setting in one `SELECT`, and the pre-request function.
210 pub fn statements(&self) -> Vec<SessionStatement> {
211 let mut statements = Vec::with_capacity(4);
212
213 // Isolation level. `SET TRANSACTION` is only accepted before the
214 // first statement of a transaction, so it goes here.
215 //
216 // One asked for with `isolation` wins, then a role's own setting, then
217 // the server-wide `PGRST_DB_TX_ISOLATION`. All are closed enums
218 // rendered by `to_sql` -- never caller text. Issued only when it
219 // differs from PostgreSQL's own default, so the common case sends
220 // nothing.
221 let role_settings = self.config.role_settings.get(&self.role);
222 let isolation = self
223 .isolation
224 .as_ref()
225 .or_else(|| role_settings.and_then(|r| r.isolation_level.as_ref()))
226 .unwrap_or(&self.config.db_tx_isolation);
227 if *isolation != IsolationLevel::ReadCommitted {
228 statements.push(SessionStatement::new(
229 format!("SET TRANSACTION ISOLATION LEVEL {}", isolation.to_sql()),
230 Vec::new(),
231 ));
232 }
233
234 // A read runs in a read-only transaction.
235 //
236 // Without this a GET can change data: `GET /rpc/some_volatile_fn`
237 // happily executes whatever the function does. PostgreSQL is the
238 // right place to enforce it, since it covers anything reachable from
239 // the statement -- a volatile function, a trigger, a rule -- rather
240 // than only what the planner thought to look at.
241 if self.read_only {
242 statements.push(SessionStatement::new(
243 "SET TRANSACTION READ ONLY",
244 Vec::new(),
245 ));
246 }
247
248 // Everything the caller tells the database about itself, in one
249 // statement.
250 //
251 // These are `SET LOCAL` by another spelling -- `set_config(k, v,
252 // true)` -- and a function or an RLS policy reads them back with
253 // `current_setting`. Sent as one `SELECT` rather than one statement
254 // each, which is a round trip per setting otherwise, and there are
255 // eight of them before a request has done any work. The values stay
256 // bound rather than interpolated: `set_config` takes both as
257 // arguments, where `SET LOCAL` would need the value written into the
258 // statement text.
259 //
260 // The order is PostgREST's: the settings a role carries, then the
261 // role, then everything about the request. The role goes on after its
262 // own settings so that a `GRANT SET ON PARAMETER` on the
263 // authenticator still applies when they are set.
264 let mut settings: Vec<(String, String)> = Vec::new();
265
266 // The settings a role carries come first, before the `role` that
267 // follows them -- see the note above on `GRANT SET ON PARAMETER`.
268 if let Some(timeout) = role_settings.and_then(|r| r.statement_timeout) {
269 settings.push(("statement_timeout".to_string(), timeout.to_string()));
270 }
271
272 if self.set_search_path {
273 settings.push(("search_path".to_string(), self.search_path()));
274 }
275 settings.push(("role".to_string(), self.role.clone()));
276 let request = [
277 ("request.method", &self.method),
278 ("request.path", &self.path),
279 ("request.headers", &self.headers),
280 ("request.cookies", &self.cookies),
281 ("request.trace_id", &self.trace_id),
282 ];
283 for (name, value) in request {
284 if let Some(value) = value {
285 settings.push((name.to_string(), value.clone()));
286 }
287 }
288 settings.extend(self.claims.iter().cloned());
289
290 // `app_settings`, exposed as PostgREST spells them: a function or
291 // policy reads one back with `current_setting('app.settings.<key>')`.
292 for (key, value) in &self.config.app_settings {
293 settings.push((format!("app.settings.{key}"), value.clone()));
294 }
295
296 // `Prefer: timezone` is applied to the session, so every value the
297 // database renders -- and every `now()` a function calls -- sees it.
298 // An unknown zone is PostgreSQL's to reject, and it does, with a
299 // message naming the value the client sent.
300 if let Some(timezone) = &self.timezone {
301 settings.push(("timezone".to_string(), timezone.clone()));
302 }
303
304 settings.extend(self.extra.iter().cloned());
305
306 let calls = (0..settings.len())
307 .map(|i| format!("set_config(${}, ${}, true)", i * 2 + 1, i * 2 + 2))
308 .collect::<Vec<_>>()
309 .join(", ");
310 let params = settings
311 .into_iter()
312 .flat_map(|(name, value)| [SqlParam::Text(name), SqlParam::Text(value)])
313 .collect();
314 statements.push(SessionStatement::new(format!("SELECT {calls}"), params));
315
316 // `db_pre_request`: a function called before the caller's own query,
317 // on the same transaction, so it sees every setting above and
318 // anything it raises aborts the transaction. PostgREST's use for it
319 // is a custom authorisation check that `RAISE`s to refuse.
320 //
321 // The name is an identifier from the configuration, not from the
322 // caller, and is escaped as one -- a schema-qualified name stays two
323 // identifiers so `auth.check` does not become `"auth.check"`.
324 if let Some(pre_request) = self
325 .config
326 .db_pre_request
327 .as_ref()
328 .filter(|_| self.pre_request)
329 {
330 let qualified = pre_request
331 .split('.')
332 .map(postrust_sql::escape_ident)
333 .collect::<Vec<_>>()
334 .join(".");
335 statements.push(SessionStatement::new(
336 format!("SELECT {qualified}()"),
337 Vec::new(),
338 ));
339 }
340
341 statements
342 }
343
344 /// The request's schema, then `db_extra_search_path`, each escaped as an
345 /// identifier.
346 fn search_path(&self) -> String {
347 let schema = self
348 .schema
349 .as_deref()
350 .unwrap_or_else(|| self.config.default_schema());
351 std::iter::once(schema)
352 .chain(self.config.db_extra_search_path.iter().map(String::as_str))
353 .map(postrust_sql::escape_ident)
354 .collect::<Vec<_>>()
355 .join(", ")
356 }
357}
358
359/// Render a map of strings as a JSON object.
360///
361/// PostgreSQL will not accept every header name as a setting name -- a `-` is
362/// not allowed in one from version 14 -- so the whole map goes into a single
363/// setting as JSON and a function picks out what it wants with `->>`.
364fn json_object(entries: &IndexMap<String, String>) -> String {
365 serde_json::Value::Object(
366 entries
367 .iter()
368 .map(|(name, value)| (name.clone(), serde_json::Value::String(value.clone())))
369 .collect(),
370 )
371 .to_string()
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::config::RoleSettings;
378 use pretty_assertions::assert_eq;
379
380 fn text(values: &[&str]) -> Vec<SqlParam> {
381 values
382 .iter()
383 .map(|v| SqlParam::Text(v.to_string()))
384 .collect()
385 }
386
387 fn set_config(n: usize) -> String {
388 let calls = (0..n)
389 .map(|i| format!("set_config(${}, ${}, true)", i * 2 + 1, i * 2 + 2))
390 .collect::<Vec<_>>()
391 .join(", ");
392 format!("SELECT {calls}")
393 }
394
395 fn claims() -> Vec<(String, String)> {
396 vec![
397 (
398 "request.jwt.claims".to_string(),
399 r#"{"role":"web_anon"}"#.to_string(),
400 ),
401 (
402 "request.jwt.claims.role".to_string(),
403 "web_anon".to_string(),
404 ),
405 ]
406 }
407
408 /// The common case: a read on the default configuration sends read-only
409 /// and the settings, and nothing else -- no isolation level, since it is
410 /// PostgreSQL's default, and no pre-request function, since none is set.
411 #[test]
412 fn a_plain_read() {
413 let config = AppConfig::default();
414 let mut headers = IndexMap::new();
415 headers.insert("accept".to_string(), "application/json".to_string());
416 let mut cookies = IndexMap::new();
417 cookies.insert("session".to_string(), "abc".to_string());
418
419 let session = Session::new(&config, "web_anon", claims())
420 .schema("api")
421 .request("GET", "/items")
422 .headers(&headers)
423 .cookies(&cookies)
424 .timezone(None)
425 .read_only(true);
426
427 assert_eq!(
428 session.statements(),
429 vec![
430 SessionStatement::new("SET TRANSACTION READ ONLY", vec![]),
431 SessionStatement::new(
432 set_config(8),
433 text(&[
434 "search_path",
435 r#""api", "public""#,
436 "role",
437 "web_anon",
438 "request.method",
439 "GET",
440 "request.path",
441 "/items",
442 "request.headers",
443 r#"{"accept":"application/json"}"#,
444 "request.cookies",
445 r#"{"session":"abc"}"#,
446 "request.jwt.claims",
447 r#"{"role":"web_anon"}"#,
448 "request.jwt.claims.role",
449 "web_anon",
450 ]),
451 ),
452 ]
453 );
454 }
455
456 /// Everything at once, in PostgREST's order: the role's isolation level
457 /// over the server's, the role's statement timeout before the role, the
458 /// app settings after the claims, the time zone last, and then the
459 /// pre-request function with each part of its name escaped.
460 #[test]
461 fn a_write_with_everything_configured() {
462 let mut config = AppConfig {
463 db_tx_isolation: IsolationLevel::RepeatableRead,
464 db_extra_search_path: vec!["ext".to_string()],
465 db_pre_request: Some("auth.check".to_string()),
466 ..AppConfig::default()
467 };
468 config.role_settings.insert(
469 "editor".to_string(),
470 RoleSettings {
471 isolation_level: Some(IsolationLevel::Serializable),
472 statement_timeout: Some(5000),
473 },
474 );
475 config
476 .app_settings
477 .insert("jwt_secret".to_string(), "s3cret".to_string());
478
479 let session = Session::new(&config, "editor", claims())
480 .schema("api")
481 .request("POST", "/items")
482 .headers(&IndexMap::new())
483 .cookies(&IndexMap::new())
484 .timezone(Some("Asia/Kolkata".to_string()));
485
486 assert_eq!(
487 session.statements(),
488 vec![
489 SessionStatement::new("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", vec![]),
490 SessionStatement::new(
491 set_config(11),
492 text(&[
493 "statement_timeout",
494 "5000",
495 "search_path",
496 r#""api", "ext""#,
497 "role",
498 "editor",
499 "request.method",
500 "POST",
501 "request.path",
502 "/items",
503 "request.headers",
504 "{}",
505 "request.cookies",
506 "{}",
507 "request.jwt.claims",
508 r#"{"role":"web_anon"}"#,
509 "request.jwt.claims.role",
510 "web_anon",
511 "app.settings.jwt_secret",
512 "s3cret",
513 "timezone",
514 "Asia/Kolkata",
515 ]),
516 ),
517 SessionStatement::new(r#"SELECT "auth"."check"()"#, vec![]),
518 ]
519 );
520 }
521
522 /// With no request, the request's settings are left out and the
523 /// `search_path` starts at the default schema; the role, the claims and
524 /// the pre-request function are all still there.
525 #[test]
526 fn a_session_with_no_request() {
527 let config = AppConfig {
528 db_schemas: vec!["api".to_string(), "other".to_string()],
529 db_pre_request: Some("check".to_string()),
530 ..AppConfig::default()
531 };
532
533 let session = Session::new(&config, "alice", vec![]);
534 assert_eq!(session.role(), "alice");
535 assert_eq!(
536 session.statements(),
537 vec![
538 SessionStatement::new(
539 set_config(2),
540 text(&["search_path", r#""api", "public""#, "role", "alice",]),
541 ),
542 SessionStatement::new(r#"SELECT "check"()"#, vec![]),
543 ]
544 );
545
546 // And the pre-request function can be left out.
547 assert_eq!(session.pre_request(false).statements().len(), 1);
548 }
549
550 /// A caller's own settings go after everything else.
551 #[test]
552 fn extra_settings_come_last() {
553 let config = AppConfig::default();
554 let session = Session::new(&config, "alice", vec![])
555 .timezone(Some("UTC".to_string()))
556 .setting("hasura.role", "alice");
557 let statements = session.statements();
558 let params = &statements[0].params;
559 assert_eq!(
560 ¶ms[params.len() - 4..],
561 &text(&["timezone", "UTC", "hasura.role", "alice"])[..]
562 );
563 }
564
565 /// An isolation level asked for wins over the role's and the server's,
566 /// and asking for PostgreSQL's default sends nothing, as it does when the
567 /// configuration says it.
568 #[test]
569 fn an_isolation_level_asked_for_wins() {
570 let mut config = AppConfig {
571 db_tx_isolation: IsolationLevel::RepeatableRead,
572 ..AppConfig::default()
573 };
574 config.role_settings.insert(
575 "editor".to_string(),
576 RoleSettings {
577 isolation_level: Some(IsolationLevel::Serializable),
578 statement_timeout: None,
579 },
580 );
581 let first = |session: Session| session.statements()[0].sql.clone();
582
583 assert_eq!(
584 first(Session::new(&config, "editor", vec![])),
585 "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"
586 );
587 assert_eq!(
588 first(
589 Session::new(&config, "editor", vec![])
590 .isolation(Some(IsolationLevel::RepeatableRead))
591 ),
592 "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
593 );
594 assert_eq!(
595 first(
596 Session::new(&config, "editor", vec![])
597 .isolation(Some(IsolationLevel::ReadCommitted))
598 ),
599 set_config(2)
600 );
601 assert_eq!(
602 first(Session::new(&config, "other", vec![]).isolation(None)),
603 "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
604 );
605 }
606
607 /// Beside the other facts about the request, and absent without one.
608 #[test]
609 fn a_trace_id_is_a_request_setting() {
610 let config = AppConfig::default();
611 let traced = Session::new(&config, "web_anon", Vec::new())
612 .request("GET", "/items")
613 .trace_id(Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()))
614 .statements();
615 let untraced = Session::new(&config, "web_anon", Vec::new())
616 .request("GET", "/items")
617 .statements();
618
619 let names = |statements: &[SessionStatement]| -> Vec<String> {
620 statements
621 .iter()
622 .flat_map(|s| s.params.iter())
623 .step_by(2)
624 .map(|p| match p {
625 SqlParam::Text(name) => name.clone(),
626 other => panic!("{other:?}"),
627 })
628 .collect()
629 };
630 assert_eq!(
631 names(&traced),
632 [
633 "search_path",
634 "role",
635 "request.method",
636 "request.path",
637 "request.trace_id"
638 ]
639 );
640 assert!(!names(&untraced).contains(&"request.trace_id".to_string()));
641 }
642
643 /// Turned off, the `search_path` is left as the role and the database
644 /// set it, and nothing else changes.
645 #[test]
646 fn the_search_path_can_be_left_alone() {
647 let config = AppConfig::default();
648 let set = Session::new(&config, "web_anon", claims()).statements();
649 let left = Session::new(&config, "web_anon", claims())
650 .set_search_path(false)
651 .statements();
652 assert_eq!(
653 left,
654 vec![SessionStatement::new(
655 set_config(3),
656 text(&[
657 "role",
658 "web_anon",
659 "request.jwt.claims",
660 r#"{"role":"web_anon"}"#,
661 "request.jwt.claims.role",
662 "web_anon",
663 ]),
664 )]
665 );
666 assert_eq!(set[0].params[0], SqlParam::Text("search_path".into()));
667 }
668}