use crate::config::{AppConfig, IsolationLevel};
use crate::db::Tx;
use indexmap::IndexMap;
use postrust_sql::SqlParam;
#[derive(Clone, Debug, PartialEq)]
pub struct SessionStatement {
pub sql: String,
pub params: Vec<SqlParam>,
}
impl SessionStatement {
fn new(sql: impl Into<String>, params: Vec<SqlParam>) -> Self {
Self {
sql: sql.into(),
params,
}
}
}
#[derive(Clone, Debug)]
pub struct Session<'a> {
config: &'a AppConfig,
role: String,
claims: Vec<(String, String)>,
schema: Option<String>,
method: Option<String>,
path: Option<String>,
headers: Option<String>,
cookies: Option<String>,
trace_id: Option<String>,
timezone: Option<String>,
extra: Vec<(String, String)>,
isolation: Option<IsolationLevel>,
read_only: bool,
pre_request: bool,
set_search_path: bool,
}
impl<'a> Session<'a> {
pub fn new(
config: &'a AppConfig,
role: impl Into<String>,
claims: Vec<(String, String)>,
) -> Self {
Self {
config,
role: role.into(),
claims,
schema: None,
method: None,
path: None,
headers: None,
cookies: None,
trace_id: None,
timezone: None,
extra: Vec::new(),
isolation: None,
read_only: false,
pre_request: true,
set_search_path: true,
}
}
pub fn schema(mut self, schema: impl Into<String>) -> Self {
self.schema = Some(schema.into());
self
}
pub fn request(mut self, method: impl Into<String>, path: impl Into<String>) -> Self {
self.method = Some(method.into());
self.path = Some(path.into());
self
}
pub fn headers(mut self, headers: &IndexMap<String, String>) -> Self {
self.headers = Some(json_object(headers));
self
}
pub fn cookies(mut self, cookies: &IndexMap<String, String>) -> Self {
self.cookies = Some(json_object(cookies));
self
}
pub fn trace_id(mut self, trace_id: Option<String>) -> Self {
self.trace_id = trace_id;
self
}
pub fn timezone(mut self, timezone: Option<String>) -> Self {
self.timezone = timezone;
self
}
pub fn setting(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra.push((name.into(), value.into()));
self
}
pub fn isolation(mut self, isolation: Option<IsolationLevel>) -> Self {
self.isolation = isolation;
self
}
pub fn read_only(mut self, read_only: bool) -> Self {
self.read_only = read_only;
self
}
pub fn set_search_path(mut self, set_search_path: bool) -> Self {
self.set_search_path = set_search_path;
self
}
pub fn pre_request(mut self, pre_request: bool) -> Self {
self.pre_request = pre_request;
self
}
pub fn role(&self) -> &str {
&self.role
}
pub fn apply<T: Tx>(&self, tx: &mut T) {
for statement in self.statements() {
tx.queue(&statement.sql, &statement.params);
}
}
pub fn statements(&self) -> Vec<SessionStatement> {
let mut statements = Vec::with_capacity(4);
let role_settings = self.config.role_settings.get(&self.role);
let isolation = self
.isolation
.as_ref()
.or_else(|| role_settings.and_then(|r| r.isolation_level.as_ref()))
.unwrap_or(&self.config.db_tx_isolation);
if *isolation != IsolationLevel::ReadCommitted {
statements.push(SessionStatement::new(
format!("SET TRANSACTION ISOLATION LEVEL {}", isolation.to_sql()),
Vec::new(),
));
}
if self.read_only {
statements.push(SessionStatement::new(
"SET TRANSACTION READ ONLY",
Vec::new(),
));
}
let mut settings: Vec<(String, String)> = Vec::new();
if let Some(timeout) = role_settings.and_then(|r| r.statement_timeout) {
settings.push(("statement_timeout".to_string(), timeout.to_string()));
}
if self.set_search_path {
settings.push(("search_path".to_string(), self.search_path()));
}
settings.push(("role".to_string(), self.role.clone()));
let request = [
("request.method", &self.method),
("request.path", &self.path),
("request.headers", &self.headers),
("request.cookies", &self.cookies),
("request.trace_id", &self.trace_id),
];
for (name, value) in request {
if let Some(value) = value {
settings.push((name.to_string(), value.clone()));
}
}
settings.extend(self.claims.iter().cloned());
for (key, value) in &self.config.app_settings {
settings.push((format!("app.settings.{key}"), value.clone()));
}
if let Some(timezone) = &self.timezone {
settings.push(("timezone".to_string(), timezone.clone()));
}
settings.extend(self.extra.iter().cloned());
let calls = (0..settings.len())
.map(|i| format!("set_config(${}, ${}, true)", i * 2 + 1, i * 2 + 2))
.collect::<Vec<_>>()
.join(", ");
let params = settings
.into_iter()
.flat_map(|(name, value)| [SqlParam::Text(name), SqlParam::Text(value)])
.collect();
statements.push(SessionStatement::new(format!("SELECT {calls}"), params));
if let Some(pre_request) = self
.config
.db_pre_request
.as_ref()
.filter(|_| self.pre_request)
{
let qualified = pre_request
.split('.')
.map(postrust_sql::escape_ident)
.collect::<Vec<_>>()
.join(".");
statements.push(SessionStatement::new(
format!("SELECT {qualified}()"),
Vec::new(),
));
}
statements
}
fn search_path(&self) -> String {
let schema = self
.schema
.as_deref()
.unwrap_or_else(|| self.config.default_schema());
std::iter::once(schema)
.chain(self.config.db_extra_search_path.iter().map(String::as_str))
.map(postrust_sql::escape_ident)
.collect::<Vec<_>>()
.join(", ")
}
}
fn json_object(entries: &IndexMap<String, String>) -> String {
serde_json::Value::Object(
entries
.iter()
.map(|(name, value)| (name.clone(), serde_json::Value::String(value.clone())))
.collect(),
)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::RoleSettings;
use pretty_assertions::assert_eq;
fn text(values: &[&str]) -> Vec<SqlParam> {
values
.iter()
.map(|v| SqlParam::Text(v.to_string()))
.collect()
}
fn set_config(n: usize) -> String {
let calls = (0..n)
.map(|i| format!("set_config(${}, ${}, true)", i * 2 + 1, i * 2 + 2))
.collect::<Vec<_>>()
.join(", ");
format!("SELECT {calls}")
}
fn claims() -> Vec<(String, String)> {
vec![
(
"request.jwt.claims".to_string(),
r#"{"role":"web_anon"}"#.to_string(),
),
(
"request.jwt.claims.role".to_string(),
"web_anon".to_string(),
),
]
}
#[test]
fn a_plain_read() {
let config = AppConfig::default();
let mut headers = IndexMap::new();
headers.insert("accept".to_string(), "application/json".to_string());
let mut cookies = IndexMap::new();
cookies.insert("session".to_string(), "abc".to_string());
let session = Session::new(&config, "web_anon", claims())
.schema("api")
.request("GET", "/items")
.headers(&headers)
.cookies(&cookies)
.timezone(None)
.read_only(true);
assert_eq!(
session.statements(),
vec![
SessionStatement::new("SET TRANSACTION READ ONLY", vec![]),
SessionStatement::new(
set_config(8),
text(&[
"search_path",
r#""api", "public""#,
"role",
"web_anon",
"request.method",
"GET",
"request.path",
"/items",
"request.headers",
r#"{"accept":"application/json"}"#,
"request.cookies",
r#"{"session":"abc"}"#,
"request.jwt.claims",
r#"{"role":"web_anon"}"#,
"request.jwt.claims.role",
"web_anon",
]),
),
]
);
}
#[test]
fn a_write_with_everything_configured() {
let mut config = AppConfig {
db_tx_isolation: IsolationLevel::RepeatableRead,
db_extra_search_path: vec!["ext".to_string()],
db_pre_request: Some("auth.check".to_string()),
..AppConfig::default()
};
config.role_settings.insert(
"editor".to_string(),
RoleSettings {
isolation_level: Some(IsolationLevel::Serializable),
statement_timeout: Some(5000),
},
);
config
.app_settings
.insert("jwt_secret".to_string(), "s3cret".to_string());
let session = Session::new(&config, "editor", claims())
.schema("api")
.request("POST", "/items")
.headers(&IndexMap::new())
.cookies(&IndexMap::new())
.timezone(Some("Asia/Kolkata".to_string()));
assert_eq!(
session.statements(),
vec![
SessionStatement::new("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", vec![]),
SessionStatement::new(
set_config(11),
text(&[
"statement_timeout",
"5000",
"search_path",
r#""api", "ext""#,
"role",
"editor",
"request.method",
"POST",
"request.path",
"/items",
"request.headers",
"{}",
"request.cookies",
"{}",
"request.jwt.claims",
r#"{"role":"web_anon"}"#,
"request.jwt.claims.role",
"web_anon",
"app.settings.jwt_secret",
"s3cret",
"timezone",
"Asia/Kolkata",
]),
),
SessionStatement::new(r#"SELECT "auth"."check"()"#, vec![]),
]
);
}
#[test]
fn a_session_with_no_request() {
let config = AppConfig {
db_schemas: vec!["api".to_string(), "other".to_string()],
db_pre_request: Some("check".to_string()),
..AppConfig::default()
};
let session = Session::new(&config, "alice", vec![]);
assert_eq!(session.role(), "alice");
assert_eq!(
session.statements(),
vec![
SessionStatement::new(
set_config(2),
text(&["search_path", r#""api", "public""#, "role", "alice",]),
),
SessionStatement::new(r#"SELECT "check"()"#, vec![]),
]
);
assert_eq!(session.pre_request(false).statements().len(), 1);
}
#[test]
fn extra_settings_come_last() {
let config = AppConfig::default();
let session = Session::new(&config, "alice", vec![])
.timezone(Some("UTC".to_string()))
.setting("hasura.role", "alice");
let statements = session.statements();
let params = &statements[0].params;
assert_eq!(
¶ms[params.len() - 4..],
&text(&["timezone", "UTC", "hasura.role", "alice"])[..]
);
}
#[test]
fn an_isolation_level_asked_for_wins() {
let mut config = AppConfig {
db_tx_isolation: IsolationLevel::RepeatableRead,
..AppConfig::default()
};
config.role_settings.insert(
"editor".to_string(),
RoleSettings {
isolation_level: Some(IsolationLevel::Serializable),
statement_timeout: None,
},
);
let first = |session: Session| session.statements()[0].sql.clone();
assert_eq!(
first(Session::new(&config, "editor", vec![])),
"SET TRANSACTION ISOLATION LEVEL SERIALIZABLE"
);
assert_eq!(
first(
Session::new(&config, "editor", vec![])
.isolation(Some(IsolationLevel::RepeatableRead))
),
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
);
assert_eq!(
first(
Session::new(&config, "editor", vec![])
.isolation(Some(IsolationLevel::ReadCommitted))
),
set_config(2)
);
assert_eq!(
first(Session::new(&config, "other", vec![]).isolation(None)),
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
);
}
#[test]
fn a_trace_id_is_a_request_setting() {
let config = AppConfig::default();
let traced = Session::new(&config, "web_anon", Vec::new())
.request("GET", "/items")
.trace_id(Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()))
.statements();
let untraced = Session::new(&config, "web_anon", Vec::new())
.request("GET", "/items")
.statements();
let names = |statements: &[SessionStatement]| -> Vec<String> {
statements
.iter()
.flat_map(|s| s.params.iter())
.step_by(2)
.map(|p| match p {
SqlParam::Text(name) => name.clone(),
other => panic!("{other:?}"),
})
.collect()
};
assert_eq!(
names(&traced),
[
"search_path",
"role",
"request.method",
"request.path",
"request.trace_id"
]
);
assert!(!names(&untraced).contains(&"request.trace_id".to_string()));
}
#[test]
fn the_search_path_can_be_left_alone() {
let config = AppConfig::default();
let set = Session::new(&config, "web_anon", claims()).statements();
let left = Session::new(&config, "web_anon", claims())
.set_search_path(false)
.statements();
assert_eq!(
left,
vec![SessionStatement::new(
set_config(3),
text(&[
"role",
"web_anon",
"request.jwt.claims",
r#"{"role":"web_anon"}"#,
"request.jwt.claims.role",
"web_anon",
]),
)]
);
assert_eq!(set[0].params[0], SqlParam::Text("search_path".into()));
}
}