Skip to main content

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            &params[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}