use postrust_auth::AuthResult;
use postrust_core::schema_cache::SchemaCacheRef;
use postrust_core::{AppConfig, Session};
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
pub type SharedWrite = Arc<tokio::sync::Mutex<Option<sqlx::Transaction<'static, sqlx::Postgres>>>>;
pub fn session_settings_for(
session: &HashMap<String, String>,
role: &str,
) -> Vec<(String, String)> {
let mut settings: Vec<(String, String)> = session
.iter()
.filter(|(name, _)| {
!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
})
.map(|(name, value)| (format!("hasura.{}", name), value.clone()))
.collect();
settings.push((
"hasura.session".to_string(),
hasura_session_document(session, role).to_string(),
));
settings.push(("hasura.role".to_string(), role.to_string()));
settings
}
pub fn hasura_session_document(session: &HashMap<String, String>, role: &str) -> serde_json::Value {
let mut object = serde_json::Map::new();
for (name, value) in session {
object.insert(
format!("x-hasura-{}", name.replace('_', "-")),
serde_json::Value::String(value.clone()),
);
}
object.insert(
"x-hasura-role".to_string(),
serde_json::Value::String(role.to_string()),
);
serde_json::Value::Object(object)
}
pub struct GraphQLContext {
pub pool: PgPool,
pub schema_cache: SchemaCacheRef,
pub auth: AuthResult,
pub session: HashMap<String, String>,
pub hasura_role: Option<String>,
pub elevated: bool,
pub write: SharedWrite,
pub config: Arc<AppConfig>,
}
impl GraphQLContext {
pub fn new(pool: PgPool, schema_cache: SchemaCacheRef, auth: AuthResult) -> Self {
Self {
pool,
schema_cache,
auth,
session: HashMap::new(),
hasura_role: None,
elevated: false,
write: Arc::new(tokio::sync::Mutex::new(None)),
config: Arc::new(AppConfig::default()),
}
}
pub fn with_config(mut self, config: Arc<AppConfig>) -> Self {
self.config = config;
self
}
pub fn with_identity(mut self, role: Option<String>, elevated: bool) -> Self {
self.hasura_role = role;
self.elevated = elevated;
self
}
pub fn acting_role(&self) -> &str {
self.hasura_role.as_deref().unwrap_or(&self.auth.role)
}
pub fn caller(&self) -> crate::role::Caller<'_> {
crate::role::Caller {
role: self.hasura_role.as_deref(),
session: &self.session,
}
}
pub fn with_write(mut self, write: SharedWrite) -> Self {
self.write = write;
self
}
pub fn with_session(mut self, session: HashMap<String, String>) -> Self {
self.session = session;
self
}
pub fn session(&self) -> Session<'_> {
session_settings_for(&self.session, self.acting_role())
.into_iter()
.fold(
Session::new(&self.config, self.role(), self.auth.claim_settings())
.pre_request(false)
.set_search_path(false),
|session, (name, value)| session.setting(name, value),
)
}
pub fn hasura_session(&self) -> serde_json::Value {
hasura_session_document(&self.session, self.acting_role())
}
pub fn role(&self) -> &str {
&self.auth.role
}
pub fn claim(&self, key: &str) -> Option<&serde_json::Value> {
self.auth.claims.get(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_auth() -> AuthResult {
let mut claims = HashMap::new();
claims.insert("user_id".into(), serde_json::json!(123));
claims.insert("role".into(), serde_json::json!("admin"));
AuthResult {
role: "authenticated".into(),
claims,
}
}
#[test]
fn test_context_role() {
let auth = create_test_auth();
assert_eq!(auth.role, "authenticated");
}
#[test]
fn test_context_claim() {
let auth = create_test_auth();
assert_eq!(auth.claims.get("user_id"), Some(&serde_json::json!(123)));
}
fn session(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect()
}
fn setting<'a>(settings: &'a [(String, String)], name: &str) -> Option<&'a str> {
settings
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
}
#[test]
fn the_session_document_says_which_role_is_asking() {
let document = hasura_session_document(&session(&[("user_id", "1")]), "user");
assert_eq!(document["x-hasura-role"], serde_json::json!("user"));
assert_eq!(document["x-hasura-user-id"], serde_json::json!("1"));
}
#[test]
fn a_session_name_gets_its_dashes_back_in_the_document() {
let document = hasura_session_document(&session(&[("infant_name", "Bittu")]), "infant");
assert_eq!(document["x-hasura-infant-name"], serde_json::json!("Bittu"));
}
#[test]
fn the_role_is_a_setting_of_its_own_beside_the_document() {
let settings = session_settings_for(&session(&[("user_id", "1")]), "Artist");
assert_eq!(setting(&settings, "hasura.role"), Some("Artist"));
assert_eq!(setting(&settings, "hasura.user_id"), Some("1"));
let document: serde_json::Value =
serde_json::from_str(setting(&settings, "hasura.session").unwrap()).unwrap();
assert_eq!(document["x-hasura-role"], serde_json::json!("Artist"));
}
fn context(auth: AuthResult) -> GraphQLContext {
GraphQLContext::new(
PgPool::connect_lazy("postgres://localhost/test").expect("valid test URL"),
SchemaCacheRef::default(),
auth,
)
}
fn sent_settings(ctx: &GraphQLContext) -> Vec<(String, String)> {
let statements = ctx.session().statements();
let carrying = statements
.iter()
.find(|s| s.sql.starts_with("SELECT set_config("))
.expect("one statement carries the settings");
carrying
.params
.chunks(2)
.map(|pair| match pair {
[postrust_sql::SqlParam::Text(name), postrust_sql::SqlParam::Text(value)] => {
(name.clone(), value.clone())
}
other => panic!("{other:?}"),
})
.collect()
}
#[tokio::test]
async fn graphql_transactions_receive_the_verified_jwt_claims_document() {
let ctx = context(create_test_auth());
let settings = sent_settings(&ctx);
let claims: serde_json::Value =
serde_json::from_str(setting(&settings, "request.jwt.claims").unwrap()).unwrap();
assert_eq!(claims["user_id"], serde_json::json!(123));
assert_eq!(claims["role"], serde_json::json!("authenticated"));
assert_eq!(
setting(&settings, "request.jwt.claims.user_id"),
Some("123")
);
assert_eq!(
setting(&settings, "request.jwt.claims.role"),
Some("authenticated")
);
}
#[tokio::test]
async fn a_graphql_session_runs_as_the_database_role_with_the_hasura_variables_last() {
let mut config = AppConfig::default();
config.db_schemas = vec!["api".to_string(), "other".to_string()];
config.db_extra_search_path = vec!["public".to_string(), "extensions".to_string()];
config.db_pre_request = Some("check_request".to_string());
let mut role = postrust_core::config::RoleSettings::default();
role.isolation_level = Some(postrust_core::config::IsolationLevel::RepeatableRead);
role.statement_timeout = Some(250);
config
.role_settings
.insert("authenticated".to_string(), role);
config
.app_settings
.insert("tenant".to_string(), "acme".to_string());
let ctx = context(create_test_auth())
.with_config(Arc::new(config))
.with_session(session(&[("user_id", "7")]))
.with_identity(Some("Artist".to_string()), false);
let statements = ctx.session().statements();
assert_eq!(
statements[0].sql, "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ",
"the role's isolation level"
);
assert_eq!(
statements.len(),
2,
"neither read-only nor db_pre_request: {statements:?}"
);
let settings = sent_settings(&ctx);
let names: Vec<&str> = settings.iter().map(|(name, _)| name.as_str()).collect();
assert_eq!(names[..2], ["statement_timeout", "role"]);
assert_eq!(
names[names.len() - 3..],
["hasura.user_id", "hasura.session", "hasura.role"]
);
let mut middle = names[2..names.len() - 3].to_vec();
middle.sort_unstable();
assert_eq!(
middle,
[
"app.settings.tenant",
"request.jwt.claims",
"request.jwt.claims.role",
"request.jwt.claims.user_id",
]
);
assert_eq!(setting(&settings, "statement_timeout"), Some("250"));
assert_eq!(setting(&settings, "search_path"), None);
assert_eq!(setting(&settings, "role"), Some("authenticated"));
assert_eq!(setting(&settings, "hasura.role"), Some("Artist"));
}
#[test]
fn a_name_that_could_not_be_a_setting_is_dropped_rather_than_written() {
let settings = session_settings_for(
&session(&[("user_id", "1"), ("bad'; drop table t; --", "x")]),
"user",
);
assert!(setting(&settings, "hasura.user_id").is_some());
assert!(settings
.iter()
.all(|(name, _)| !name.contains("drop table")));
}
}