use postrust_auth::AuthResult;
use postrust_core::schema_cache::SchemaCacheRef;
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,
}
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)),
}
}
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_settings(&self) -> Vec<(String, String)> {
let mut settings = session_settings_for(&self.session, self.acting_role());
settings.extend(self.auth.claim_settings());
settings
}
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"));
}
#[tokio::test]
async fn graphql_transactions_receive_the_verified_jwt_claims_document() {
let ctx = GraphQLContext::new(
PgPool::connect_lazy("postgres://localhost/test").expect("valid test URL"),
SchemaCacheRef::default(),
create_test_auth(),
);
let settings = ctx.session_settings();
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")
);
}
#[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")));
}
}