Support receiving JSON values from Webhooks/JWT/NoAuth instead of just strings (#1257)

### What

Previously, we only supported String as the type that contained your
session variable value when they are provided from webhooks/JWT/NoAuth.
We then coerced that value into whatever type was actually expected (eg
a float) later.

However, when we added support for array-typed session variables (#1221)
we didn't actually allow you to provide a JSON array of values as a
session variable value. You had to provide a string that contained a
JSON-encoded array of values. This meant that webhooks/JWT/NoAuth had to
double JSON-encode their session variables when returning them.

This PR fixes this and makes it so that webhooks/JWT/NoAuth can return
JSON values for session variables and that JSON is respected. So if a
session variable needs to be an array of integers, they can simply
return the JSON array of integers as the value for that session
variable.

### How

Instead of holding a `SessionVariableValue` as a `String`, we now turn
that into an enum where we have an "unparsed" String (used for when we
don't receive JSON, we just receive a string value (ie. http headers)),
or a "parsed" JSON value. When we receive session variables from
webhooks/JWT/NoAuth, we relax the restriction that they can only return
us JSON strings, and instead allow them to return JSON Values, which we
put in the new `SessionVariableValue::Parsed` enum variant. HTTP headers
go into `SessionVariableValue::Unparsed`.

Then, when we go to get the required value from the
`SessionVariableValue` based on the desired type, we either parse it out
of the "unparsed" String, or we expect that the value is already in the
correct form in the "parsed" JSON value. This is the behaviour you will
get if JSON session variables are turned on in the Flags.

If JSON session variables are not turned on, then we expect that only
String session variables (parsed or unparsed) are provided from
headers/webhooks/JWT/NoAuth, and so we run the old logic of always
expecting a String and parsing the correct value out of it.

V3_GIT_ORIGIN_REV_ID: b6734ad5443b7d68065f91aea71386c893aa7eba
This commit is contained in:
Daniel Chambers 2024-10-22 22:25:10 +11:00 committed by hasura-bot
parent 997d147c37
commit f68438b78e
34 changed files with 840 additions and 124 deletions

2
v3/Cargo.lock generated
View File

@ -2425,12 +2425,14 @@ version = "3.0.0"
dependencies = [
"axum",
"axum-core",
"derive_more 0.99.18",
"http 1.1.0",
"lang-graphql",
"open-dds",
"pretty_assertions",
"schemars",
"serde",
"serde_json",
"thiserror",
]

View File

@ -9,6 +9,15 @@
### Fixed
- When the `CompatibilityConfig` date is set to `2024-10-16` or newer, session
variables returned by webhooks, set in `noAuth` config in `AuthConfig` or set
in JWT claims are now correctly allowed to be full JSON values, not just JSON
strings. This fixes the bug where you were incorrectly required to JSON-encode
your JSON value inside a string. For example, you were previously incorrectly
required to return session variables like this
`{ "X-Hasura-AllowedUserIds": "[1,2,3]" }`, but now you can correctly return
them like this: `{ "X-Hasura-AllowedUserIds": [1,2,3] }`.
### Changed
## [v2024.10.21]

View File

@ -13,9 +13,11 @@ open-dds = { path = "../../open-dds" }
axum = { workspace = true }
axum-core = { workspace = true }
derive_more = { workspace = true }
http = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]

View File

@ -26,16 +26,73 @@ pub use open_dds::{
session_variables::{SessionVariableName, SessionVariableReference, SESSION_VARIABLE_ROLE},
};
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize, JsonSchema)]
/// Value of a session variable
pub struct SessionVariableValue(pub String);
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, derive_more::Display)]
/// Value of a session variable, used to capture session variable input from parsed sources (jwt, webhook, etc)
/// and unparsed sources (http headers)
pub enum SessionVariableValue {
/// An unparsed session variable value as a string. Might be a raw string, might be a number, might be json.
/// How we interpret it depends on what type we're trying to coerce to from the string
#[display(fmt = "{_0}")]
Unparsed(String),
/// A parsed JSON session variable value. We know what the type is because we parsed it from JSON.
#[display(fmt = "{_0}")]
Parsed(serde_json::Value),
}
impl SessionVariableValue {
pub fn new(value: &str) -> Self {
SessionVariableValue(value.to_string())
SessionVariableValue::Unparsed(value.to_string())
}
/// Assert that a session variable represents a string, regardless of encoding
pub fn as_str(&self) -> Option<&str> {
match self {
SessionVariableValue::Unparsed(s) => Some(s.as_str()),
SessionVariableValue::Parsed(value) => value.as_str(),
}
}
pub fn as_i64(&self) -> Option<i64> {
match self {
SessionVariableValue::Unparsed(s) => s.parse::<i64>().ok(),
SessionVariableValue::Parsed(value) => value.as_i64(),
}
}
pub fn as_f64(&self) -> Option<f64> {
match self {
SessionVariableValue::Unparsed(s) => s.parse::<f64>().ok(),
SessionVariableValue::Parsed(value) => value.as_f64(),
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
SessionVariableValue::Unparsed(s) => s.parse::<bool>().ok(),
SessionVariableValue::Parsed(value) => value.as_bool(),
}
}
pub fn as_value(&self) -> serde_json::Result<serde_json::Value> {
match self {
SessionVariableValue::Unparsed(s) => serde_json::from_str(s),
SessionVariableValue::Parsed(value) => Ok(value.clone()),
}
}
}
impl From<JsonSessionVariableValue> for SessionVariableValue {
fn from(value: JsonSessionVariableValue) -> Self {
SessionVariableValue::Parsed(value.0)
}
}
/// JSON value of a session variable
// This is used instead of SessionVariableValue when only JSON session variable values are accepted
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone, JsonSchema)]
#[schemars(rename = "SessionVariableValue")] // Renamed to keep json schema compatibility
pub struct JsonSessionVariableValue(pub serde_json::Value);
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub struct SessionVariables(HashMap<SessionVariableName, SessionVariableValue>);
@ -184,16 +241,17 @@ pub fn authorize_identity(
// traverse through the headers and collect role and session variables
for (header_name, header_value) in headers {
if let Ok(session_variable) = SessionVariableName::from_str(header_name.as_str()) {
let variable_value = match header_value.to_str() {
let variable_value_str = match header_value.to_str() {
Err(e) => Err(SessionError::InvalidHeaderValue {
header_name: header_name.to_string(),
error: e.to_string(),
})?,
Ok(h) => SessionVariableValue::new(h),
Ok(h) => h,
};
let variable_value = SessionVariableValue::Unparsed(variable_value_str.to_string());
if session_variable == SESSION_VARIABLE_ROLE {
role = Some(Role::new(&variable_value.0));
role = Some(Role::new(variable_value_str));
} else {
// TODO: Handle the duplicate case?
session_variables.insert(session_variable, variable_value);

View File

@ -16,7 +16,11 @@ fn build_allowed_roles(
// Note: The same `custom_claims` is being cloned
// for every role present in the allowed roles.
// We should think of having common claims.
session_variables: hasura_claims.custom_claims.clone(),
session_variables: hasura_claims
.custom_claims
.iter()
.map(|(k, v)| (k.clone(), v.clone().into()))
.collect(),
allowed_session_variables_from_request: auth_base::SessionVariableList::Some(
HashSet::new(),
),
@ -75,7 +79,14 @@ pub async fn authenticate_request(
let role = hasura_claims
.custom_claims
.get(&SESSION_VARIABLE_ROLE)
.map(|v| Role::new(v.0.as_str()));
.map(|v| {
Ok::<_, Error>(Role::new(v.0.as_str().ok_or_else(
|| Error::ClaimMustBeAString {
claim_name: SESSION_VARIABLE_ROLE.to_string(),
},
)?))
})
.transpose()?;
match role {
// `x-hasura-role` is found, check if it's the
// role that can emulate by comparing it to
@ -111,6 +122,7 @@ mod tests {
use std::str::FromStr;
use auth_base::{RoleAuthorization, SessionVariableValue};
use hasura_authn_core::JsonSessionVariableValue;
use jsonwebtoken as jwt;
use jsonwebtoken::Algorithm;
use jwt::{encode, EncodingKey};
@ -168,7 +180,7 @@ mod tests {
let mut hasura_custom_claims = HashMap::new();
hasura_custom_claims.insert(
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
SessionVariableValue("1".to_string()),
JsonSessionVariableValue(json!("1")),
);
HasuraClaims {
default_role: Role::new("user"),
@ -225,7 +237,7 @@ mod tests {
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
SessionVariableValue::new("1"),
SessionVariableValue::Parsed(json!("1")),
);
expected_allowed_roles.insert(
test_role.clone(),
@ -274,7 +286,7 @@ mod tests {
let mut hasura_claims = get_default_hasura_claims();
hasura_claims.custom_claims.insert(
SessionVariableName::from_str("x-hasura-role").unwrap(),
SessionVariableValue::new("admin"),
JsonSessionVariableValue(json!("admin")),
);
let encoded_claims = get_encoded_claims(Algorithm::HS256, &hasura_claims)?;

View File

@ -4,7 +4,7 @@ use std::time::Duration;
use axum::http::{HeaderMap, HeaderValue};
use axum::response::IntoResponse;
use cookie::{self, Cookie};
use hasura_authn_core::{Role, SessionVariableValue};
use hasura_authn_core::{JsonSessionVariableValue, Role};
use jsonptr::Pointer;
use jsonwebtoken::{self as jwt, decode, DecodingKey, Validation};
use jwt::decode_header;
@ -40,6 +40,8 @@ pub enum Error {
claim_name: String,
err: serde_json::Error,
},
#[error("Expected string value for claim {claim_name}")]
ClaimMustBeAString { claim_name: String },
#[error("Required claim {claim_name} not found")]
RequiredClaimNotFound { claim_name: String },
#[error("JWT Authorization token source: Header name {header_name} not found.")]
@ -114,7 +116,8 @@ impl Error {
header_name: _,
}
| Error::CookieParseError { err: _ }
| Error::MissingCookieValue { cookie_name: _ } => StatusCode::BAD_REQUEST,
| Error::MissingCookieValue { cookie_name: _ }
| Error::ClaimMustBeAString { claim_name: _ } => StatusCode::BAD_REQUEST,
}
}
}
@ -254,7 +257,7 @@ pub struct JWTClaimsMap {
/// A dictionary of the custom claims, where the key is the name of the claim and the value
/// is the JSON pointer to lookup the custom claims within the decoded JWT.
pub custom_claims:
Option<HashMap<SessionVariableName, JWTClaimsMappingEntry<SessionVariableValue>>>,
Option<HashMap<SessionVariableName, JWTClaimsMappingEntry<JsonSessionVariableValue>>>,
}
#[derive(Serialize, Deserialize, PartialEq, Clone, JsonSchema, Debug)]
@ -391,7 +394,7 @@ pub struct HasuraClaims {
/// as per the user's defined permissions.
/// For example, things like `x-hasura-user-id` can go here.
#[serde(flatten)]
pub custom_claims: HashMap<SessionVariableName, SessionVariableValue>,
pub custom_claims: HashMap<SessionVariableName, JsonSessionVariableValue>,
}
#[derive(Debug, Serialize, Deserialize)]
@ -735,7 +738,7 @@ mod tests {
let mut hasura_custom_claims = HashMap::new();
hasura_custom_claims.insert(
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
SessionVariableValue("1".to_string()),
JsonSessionVariableValue(json!("1")),
);
HasuraClaims {
default_role: Role::new("user"),

View File

@ -1,4 +1,4 @@
use hasura_authn_core::{Role, SessionVariableName, SessionVariableValue};
use hasura_authn_core::{JsonSessionVariableValue, Role, SessionVariableName};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
@ -15,7 +15,7 @@ pub struct NoAuthConfig {
pub role: Role,
/// static session variables to use whilst running the engine
#[schemars(title = "SessionVariables")]
pub session_variables: HashMap<SessionVariableName, SessionVariableValue>,
pub session_variables: HashMap<SessionVariableName, JsonSessionVariableValue>,
}
impl NoAuthConfig {
@ -40,7 +40,11 @@ pub fn identity_from_config(no_auth_config: &NoAuthConfig) -> hasura_authn_core:
no_auth_config.role.clone(),
hasura_authn_core::RoleAuthorization {
role: no_auth_config.role.clone(),
session_variables: no_auth_config.session_variables.clone(),
session_variables: no_auth_config
.session_variables
.iter()
.map(|(k, v)| (k.clone(), v.clone().into()))
.collect(),
allowed_session_variables_from_request: hasura_authn_core::SessionVariableList::Some(
HashSet::new(),
),

View File

@ -46,6 +46,8 @@ pub enum InternalError {
ReqwestError(reqwest::Error),
#[error("'x-hasura-role' session variable not found in the webhook response.")]
RoleSessionVariableNotFound,
#[error("'x-hasura-role' session variable in the webhook response was not a string.")]
RoleSessionVariableMustBeString,
}
impl TraceableError for InternalError {
@ -202,14 +204,14 @@ async fn make_auth_hook_request(
match response.status() {
reqwest::StatusCode::UNAUTHORIZED => Err(Error::AuthenticationFailed),
reqwest::StatusCode::OK => {
let auth_hook_response: HashMap<String, String> =
let auth_hook_response: HashMap<String, serde_json::Value> =
response.json().await.map_err(InternalError::ReqwestError)?;
let mut session_variables = HashMap::new();
for (k, v) in &auth_hook_response {
match SessionVariableName::from_str(k) {
Ok(session_variable) => {
session_variables
.insert(session_variable, SessionVariableValue(v.to_string()));
.insert(session_variable, SessionVariableValue::Parsed(v.clone()));
}
Err(_e) => {}
}
@ -218,8 +220,8 @@ async fn make_auth_hook_request(
session_variables
.get(&session_variables::SESSION_VARIABLE_ROLE)
.ok_or(InternalError::RoleSessionVariableNotFound)?
.0
.as_str(),
.as_str()
.ok_or_else(|| InternalError::RoleSessionVariableMustBeString)?,
);
let role_authorization = RoleAuthorization {
role: role.clone(),
@ -324,6 +326,7 @@ mod tests {
use mockito;
use rand::{thread_rng, Rng};
use reqwest::header::CONTENT_TYPE;
use serde_json::json;
#[tokio::test]
// This test emulates a successful authentication by the webhook using Get method
@ -367,11 +370,11 @@ mod tests {
let mut role_authorization_session_variables = HashMap::new();
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-role").unwrap(),
SessionVariableValue::new("test-role"),
SessionVariableValue::Parsed(json!("test-role")),
);
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-test-role-id").unwrap(),
SessionVariableValue::new("1"),
SessionVariableValue::Parsed(json!("1")),
);
expected_allowed_roles.insert(
test_role.clone(),
@ -438,11 +441,11 @@ mod tests {
let mut role_authorization_session_variables = HashMap::new();
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-role").unwrap(),
SessionVariableValue::new("test-role"),
SessionVariableValue::Parsed(json!("test-role")),
);
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-test-role-id").unwrap(),
SessionVariableValue::new("1"),
SessionVariableValue::Parsed(json!("1")),
);
expected_allowed_roles.insert(
test_role.clone(),
@ -510,15 +513,15 @@ mod tests {
let mut role_authorization_session_variables = HashMap::new();
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-role").unwrap(),
SessionVariableValue::new("test-role"),
SessionVariableValue::Parsed(json!("test-role")),
);
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-test-role-id").unwrap(),
SessionVariableValue::new("1"),
SessionVariableValue::Parsed(json!("1")),
);
role_authorization_session_variables.insert(
SessionVariableName::from_str("status").unwrap(),
SessionVariableValue::new("true"),
SessionVariableValue::Parsed(json!("true")),
);
expected_allowed_roles.insert(
test_role.clone(),
@ -638,11 +641,11 @@ mod tests {
let mut role_authorization_session_variables = HashMap::new();
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-role").unwrap(),
SessionVariableValue::new("user"),
SessionVariableValue::Parsed(json!("user")),
);
role_authorization_session_variables.insert(
SessionVariableName::from_str("x-hasura-user-id").unwrap(),
SessionVariableValue::new("1"),
SessionVariableValue::Parsed(json!("1")),
);
expected_allowed_roles.insert(
test_role.clone(),

View File

@ -480,8 +480,7 @@
]
},
"SessionVariableValue": {
"description": "Value of a session variable",
"type": "string"
"description": "JSON value of a session variable"
},
"JWTClaimsMappingPathEntry_for_SessionVariableValue": {
"$id": "https://hasura.io/jsonschemas/metadata/JWTClaimsMappingPathEntry_for_SessionVariableValue",

View File

@ -3,7 +3,9 @@ use execute::{HttpContext, ProjectId};
use goldenfile::{differs::text_diff, Mint};
use graphql_frontend::execute_query;
use graphql_schema::GDS;
use hasura_authn_core::{Identity, Role, Session, SessionError, SessionVariableValue};
use hasura_authn_core::{
Identity, JsonSessionVariableValue, Role, Session, SessionError, SessionVariableValue,
};
use lang_graphql::ast::common as ast;
use lang_graphql::{http::RawRequest, schema::Schema};
use metadata_resolve::{data_connectors::NdcVersion, LifecyclePluginConfigs};
@ -47,7 +49,15 @@ pub(crate) fn resolve_session(
let authorization = Identity::admin(Role::new("admin"));
let role = session_variables
.get(&SESSION_VARIABLE_ROLE)
.map(|v| Role::new(&v.0));
.map(|v| {
Ok(Role::new(v.as_str().ok_or_else(|| {
SessionError::InvalidHeaderValue {
header_name: SESSION_VARIABLE_ROLE.to_string(),
error: "session variable value is not a string".to_owned(),
}
})?))
})
.transpose()?;
let role_authorization = authorization.get_role_authorization(role.as_ref())?;
let session = role_authorization.build_session(session_variables);
Ok(session)
@ -107,11 +117,18 @@ pub(crate) fn test_introspection_expectation(
let request_headers = reqwest::header::HeaderMap::new();
let session_vars_path = &test_path.join("session_variables.json");
let sessions: Vec<HashMap<SessionVariableName, SessionVariableValue>> =
let sessions: Vec<HashMap<SessionVariableName, JsonSessionVariableValue>> =
json::from_str(read_to_string(session_vars_path)?.as_ref())?;
let sessions: Vec<Session> = sessions
.into_iter()
.map(resolve_session)
.map(|session_vars| {
resolve_session(
session_vars
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
)
})
.collect::<Result<_, _>>()?;
let raw_request = RawRequest {
@ -266,11 +283,18 @@ pub fn test_execution_expectation_for_multiple_ndc_versions(
let request_headers = reqwest::header::HeaderMap::new();
let session_vars_path = &test_path.join("session_variables.json");
let sessions: Vec<HashMap<SessionVariableName, SessionVariableValue>> =
let sessions: Vec<HashMap<SessionVariableName, JsonSessionVariableValue>> =
json::from_str(read_to_string(session_vars_path)?.as_ref())?;
let sessions: Vec<Session> = sessions
.into_iter()
.map(resolve_session)
.map(|session_vars| {
resolve_session(
session_vars
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
)
})
.collect::<Result<_, _>>()?;
// expected response headers are a `Vec<String>`; one set for each
@ -469,11 +493,11 @@ pub fn test_execute_explain(
let schema = GDS::build_schema(&gds)?;
let request_headers = reqwest::header::HeaderMap::new();
let session = {
let session_variables_raw = r#"{
"x-hasura-role": "admin"
}"#;
let session_variables: HashMap<SessionVariableName, SessionVariableValue> =
serde_json::from_str(session_variables_raw)?;
HashMap::from_iter([(
SESSION_VARIABLE_ROLE.clone(),
SessionVariableValue::Unparsed("admin".to_owned()),
)]);
resolve_session(session_variables)
}?;
let query = read_to_string(&root_test_dir.join(gql_request_file_path))?;
@ -579,9 +603,14 @@ pub(crate) fn test_sql(test_path_string: &str) -> anyhow::Result<()> {
let session = Arc::new({
let session_vars_path = &test_path.join("session_variables.json");
let session_variables: HashMap<SessionVariableName, SessionVariableValue> =
let session_variables: HashMap<SessionVariableName, JsonSessionVariableValue> =
serde_json::from_str(read_to_string(session_vars_path)?.as_ref())?;
resolve_session(session_variables)
resolve_session(
session_variables
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
)
}?);
let catalog = Arc::new(sql::catalog::Catalog::from_metadata(gds.metadata));

View File

@ -0,0 +1,192 @@
{
"version": "v2",
"flags": {
"json_session_variables": false
},
"subgraphs": [
{
"name": "default",
"objects": [
{
"kind": "BooleanExpressionType",
"version": "v1",
"definition": {
"name": "int_bool_exp",
"operand": {
"scalar": {
"type": "Int",
"comparisonOperators": [
{
"name": "_eq",
"argumentType": "Int!"
},
{
"name": "_in",
"argumentType": "[Int!]"
}
],
"dataConnectorOperatorMapping": [
{
"dataConnectorName": "db",
"dataConnectorScalarType": "int4",
"operatorMapping": {}
}
]
}
},
"logicalOperators": {
"enable": true
},
"isNull": {
"enable": true
},
"graphql": {
"typeName": "Int_Filter"
}
}
},
{
"kind": "ObjectType",
"version": "v1",
"definition": {
"name": "author",
"fields": [
{
"name": "author_id",
"type": "Int!"
},
{
"name": "first_name",
"type": "String!"
},
{
"name": "last_name",
"type": "String!"
}
],
"graphql": {
"typeName": "Author"
},
"dataConnectorTypeMapping": [
{
"dataConnectorName": "db",
"dataConnectorObjectType": "author",
"fieldMapping": {
"author_id": {
"column": {
"name": "id"
}
},
"first_name": {
"column": {
"name": "first_name"
}
},
"last_name": {
"column": {
"name": "last_name"
}
}
}
}
]
}
},
{
"kind": "BooleanExpressionType",
"version": "v1",
"definition": {
"name": "author_bool_exp",
"operand": {
"object": {
"type": "author",
"comparableFields": [
{
"fieldName": "author_id",
"booleanExpressionType": "int_bool_exp"
}
],
"comparableRelationships": []
}
},
"logicalOperators": {
"enable": true
},
"isNull": {
"enable": true
},
"graphql": {
"typeName": "Author_Filter"
}
}
},
{
"kind": "Model",
"version": "v1",
"definition": {
"name": "Authors",
"objectType": "author",
"source": {
"dataConnectorName": "db",
"collection": "author"
},
"graphql": {
"selectUniques": [],
"selectMany": {
"queryRootField": "AuthorMany"
},
"orderByExpressionType": "Author_Order_By"
},
"filterExpressionType": "author_bool_exp",
"orderableFields": [
{
"fieldName": "author_id",
"orderByDirections": {
"enableAll": true
}
}
]
}
},
{
"kind": "TypePermissions",
"version": "v1",
"definition": {
"typeName": "author",
"permissions": [
{
"role": "user",
"output": {
"allowedFields": ["author_id", "first_name", "last_name"]
}
}
]
}
},
{
"kind": "ModelPermissions",
"version": "v1",
"definition": {
"modelName": "Authors",
"permissions": [
{
"role": "user",
"select": {
"filter": {
"fieldComparison": {
"field": "author_id",
"operator": "_eq",
"value": {
"sessionVariable": "x-hasura-allowed-author-id"
}
}
}
}
}
]
}
}
]
}
]
}

View File

@ -0,0 +1,11 @@
[
{
"data": {
"AuthorMany": [
{
"author_id": 1
}
]
}
}
]

View File

@ -0,0 +1,5 @@
query MyQuery {
AuthorMany {
author_id
}
}

View File

@ -0,0 +1,6 @@
[
{
"x-hasura-role": "user",
"x-hasura-allowed-author-ids": [1]
}
]

View File

@ -0,0 +1,11 @@
[
{
"data": {
"AuthorMany": [
{
"author_id": 1
}
]
}
}
]

View File

@ -0,0 +1,192 @@
{
"version": "v2",
"flags": {
"json_session_variables": true
},
"subgraphs": [
{
"name": "default",
"objects": [
{
"kind": "BooleanExpressionType",
"version": "v1",
"definition": {
"name": "int_bool_exp",
"operand": {
"scalar": {
"type": "Int",
"comparisonOperators": [
{
"name": "_eq",
"argumentType": "Int!"
},
{
"name": "_in",
"argumentType": "[Int!]"
}
],
"dataConnectorOperatorMapping": [
{
"dataConnectorName": "db",
"dataConnectorScalarType": "int4",
"operatorMapping": {}
}
]
}
},
"logicalOperators": {
"enable": true
},
"isNull": {
"enable": true
},
"graphql": {
"typeName": "Int_Filter"
}
}
},
{
"kind": "ObjectType",
"version": "v1",
"definition": {
"name": "author",
"fields": [
{
"name": "author_id",
"type": "Int!"
},
{
"name": "first_name",
"type": "String!"
},
{
"name": "last_name",
"type": "String!"
}
],
"graphql": {
"typeName": "Author"
},
"dataConnectorTypeMapping": [
{
"dataConnectorName": "db",
"dataConnectorObjectType": "author",
"fieldMapping": {
"author_id": {
"column": {
"name": "id"
}
},
"first_name": {
"column": {
"name": "first_name"
}
},
"last_name": {
"column": {
"name": "last_name"
}
}
}
}
]
}
},
{
"kind": "BooleanExpressionType",
"version": "v1",
"definition": {
"name": "author_bool_exp",
"operand": {
"object": {
"type": "author",
"comparableFields": [
{
"fieldName": "author_id",
"booleanExpressionType": "int_bool_exp"
}
],
"comparableRelationships": []
}
},
"logicalOperators": {
"enable": true
},
"isNull": {
"enable": true
},
"graphql": {
"typeName": "Author_Filter"
}
}
},
{
"kind": "Model",
"version": "v1",
"definition": {
"name": "Authors",
"objectType": "author",
"source": {
"dataConnectorName": "db",
"collection": "author"
},
"graphql": {
"selectUniques": [],
"selectMany": {
"queryRootField": "AuthorMany"
},
"orderByExpressionType": "Author_Order_By"
},
"filterExpressionType": "author_bool_exp",
"orderableFields": [
{
"fieldName": "author_id",
"orderByDirections": {
"enableAll": true
}
}
]
}
},
{
"kind": "TypePermissions",
"version": "v1",
"definition": {
"typeName": "author",
"permissions": [
{
"role": "user",
"output": {
"allowedFields": ["author_id", "first_name", "last_name"]
}
}
]
}
},
{
"kind": "ModelPermissions",
"version": "v1",
"definition": {
"modelName": "Authors",
"permissions": [
{
"role": "user",
"select": {
"filter": {
"fieldComparison": {
"field": "author_id",
"operator": "_eq",
"value": {
"sessionVariable": "x-hasura-allowed-author-id"
}
}
}
}
}
]
}
}
]
}
]
}

View File

@ -0,0 +1,5 @@
query MyQuery {
AuthorMany {
author_id
}
}

View File

@ -0,0 +1,6 @@
[
{
"x-hasura-role": "user",
"x-hasura-allowed-author-id": 1
}
]

View File

@ -129,17 +129,6 @@ fn test_model_select_many_empty_select() -> anyhow::Result<()> {
)
}
#[test]
fn test_model_select_many_array_session_variable() -> anyhow::Result<()> {
let test_path_string = "execute/models/select_many/array_session_variable";
let common_metadata_path_string = "execute/common_metadata/postgres_connector_schema.json";
common::test_execution_expectation(
test_path_string,
&[common_metadata_path_string],
common::TestOpenDDPipeline::TestNDCResponses,
)
}
#[test]
fn test_model_select_many_field_arguments() -> anyhow::Result<()> {
common::test_execution_expectation_for_multiple_ndc_versions(
@ -2096,3 +2085,38 @@ fn test_command_query_forwarded_headers() -> anyhow::Result<()> {
common::TestOpenDDPipeline::TestNDCResponses,
)
}
// Tests of session variables
#[test]
fn test_session_variables_json_enabled_array_session_variable() -> anyhow::Result<()> {
let test_path_string = "execute/session_variables/json_enabled/array_session_variable";
let common_metadata_path_string = "execute/common_metadata/postgres_connector_schema.json";
common::test_execution_expectation(
test_path_string,
&[common_metadata_path_string],
common::TestOpenDDPipeline::TestNDCResponses,
)
}
#[test]
fn test_session_variables_json_enabled_integer_session_variable() -> anyhow::Result<()> {
let test_path_string = "execute/session_variables/json_enabled/integer_session_variable";
let common_metadata_path_string = "execute/common_metadata/postgres_connector_schema.json";
common::test_execution_expectation(
test_path_string,
&[common_metadata_path_string],
common::TestOpenDDPipeline::TestNDCResponses,
)
}
#[test]
fn test_session_variables_json_disabled_integer_session_variable() -> anyhow::Result<()> {
let test_path_string = "execute/session_variables/json_disabled/integer_session_variable";
let common_metadata_path_string = "execute/common_metadata/postgres_connector_schema.json";
common::test_execution_expectation(
test_path_string,
&[common_metadata_path_string],
common::TestOpenDDPipeline::TestNDCResponses,
)
}

View File

@ -0,0 +1,52 @@
---
source: crates/engine/tests/common.rs
expression: query_ir
---
V1(
QueryRequestV1 {
queries: {
Alias(
Identifier(
"AuthorMany",
),
): Model(
ModelSelection {
target: ModelTarget {
subgraph: SubgraphName(
"default",
),
model_name: ModelName(
Identifier(
"Authors",
),
),
arguments: {},
filter: None,
order_by: [],
limit: None,
offset: None,
},
selection: {
Alias(
Identifier(
"author_id",
),
): Field(
ObjectFieldSelection {
target: ObjectFieldTarget {
field_name: FieldName(
Identifier(
"author_id",
),
),
arguments: {},
},
selection: None,
},
),
},
},
),
},
},
)

View File

@ -1,13 +0,0 @@
---
source: crates/engine/tests/common.rs
expression: rowsets
---
[
{
"rows": [
{
"author_id": 1
}
]
}
]

View File

@ -0,0 +1,15 @@
---
source: crates/engine/tests/common.rs
expression: rowsets
---
{
"Ok": [
{
"rows": [
{
"author_id": 1
}
]
}
]
}

View File

@ -0,0 +1,15 @@
---
source: crates/engine/tests/common.rs
expression: rowsets
---
{
"Ok": [
{
"rows": [
{
"author_id": 1
}
]
}
]
}

View File

@ -162,7 +162,7 @@ mod tests {
// TODO: remove duplication between this function and 'add_session'
fn resolve_session(session_vars_path: PathBuf) -> Session {
let authorization = Identity::admin(Role::new("admin"));
let session_variables: HashMap<SessionVariableName, SessionVariableValue> = {
let session_variables: HashMap<SessionVariableName, String> = {
if session_vars_path.exists() {
json::from_str(fs::read_to_string(session_vars_path).unwrap().as_ref()).unwrap()
} else {
@ -172,7 +172,11 @@ mod tests {
let role = session_variables
.get(&SESSION_VARIABLE_ROLE)
.map(|v| Role::new(&v.0));
.map(|v| Role::new(v));
let session_variables = session_variables
.into_iter()
.map(|(k, v)| (k, SessionVariableValue::Unparsed(v)))
.collect();
authorization
.get_role_authorization(role.as_ref())
.unwrap()

View File

@ -122,11 +122,28 @@ pub enum InternalDeveloperError {
session_variable: SessionVariableName,
},
#[error("Unable to typecast session variable. Expected: {expected:}, but found: {found:}")]
VariableTypeCast { expected: String, found: String },
#[error("The session variables {session_variable} is not encoded as a string. JSON-typed session variables are not supported unless you update your compatibility date")]
VariableJsonNotSupported {
session_variable: SessionVariableName,
},
#[error("Typecasting to array is not supported.")]
VariableArrayTypeCast,
#[error("Session variable {session_variable} value is of an unexpected type. Expected: {expected}, but found: {found}")]
VariableTypeCast {
session_variable: SessionVariableName,
expected: String,
found: String,
},
#[error("Typecasting session variable {session_variable} to an array is not supported. Update your compatibility date to enable JSON session variables")]
VariableArrayTypeCastNotSupported {
session_variable: SessionVariableName,
},
#[error("Expected session variable {session_variable} to be a valid JSON value, but encountered a JSON parsing error: {parse_error}")]
VariableExpectedJson {
session_variable: SessionVariableName,
parse_error: serde_json::Error,
},
#[error("Mapping for the {mapping_kind} typename {type_name:} not found")]
TypenameMappingNotFound {

View File

@ -1,4 +1,4 @@
use hasura_authn_core::{SessionVariableValue, SessionVariables};
use hasura_authn_core::{SessionVariableName, SessionVariableValue, SessionVariables};
use lang_graphql::normalized_ast;
use std::collections::BTreeMap;
@ -229,7 +229,12 @@ pub(crate) fn make_argument_from_value_expression(
}
})?;
typecast_session_variable(session_var.passed_as_json, value, value_type)
typecast_session_variable(
&session_var.name,
value,
session_var.passed_as_json,
value_type,
)
}
}
}
@ -254,7 +259,12 @@ pub(crate) fn make_argument_from_value_expression_or_predicate<'s>(
})?;
Ok(Argument::Literal {
value: typecast_session_variable(session_var.passed_as_json, value, value_type)?,
value: typecast_session_variable(
&session_var.name,
value,
session_var.passed_as_json,
value_type,
)?,
})
}
metadata_resolve::ValueExpressionOrPredicate::BooleanExpression(model_predicate) => {
@ -274,22 +284,31 @@ pub(crate) fn make_argument_from_value_expression_or_predicate<'s>(
/// Typecast a stringified session variable into a given type, but as a serde_json::Value
fn typecast_session_variable(
passed_as_json: bool,
session_var_name: &SessionVariableName,
session_var_value_wrapped: &SessionVariableValue,
passed_as_json: bool,
to_type: &QualifiedTypeReference,
) -> Result<serde_json::Value, error::Error> {
if passed_as_json {
typecast_session_variable_v2(session_var_value_wrapped, to_type)
typecast_session_variable_v2(session_var_name, session_var_value_wrapped, to_type)
} else {
typecast_session_variable_v1(session_var_value_wrapped, to_type)
typecast_session_variable_v1(session_var_name, session_var_value_wrapped, to_type)
}
}
fn typecast_session_variable_v1(
session_var_name: &SessionVariableName,
session_var_value_wrapped: &SessionVariableValue,
to_type: &QualifiedTypeReference,
) -> Result<serde_json::Value, error::Error> {
let session_var_value = &session_var_value_wrapped.0;
// In v1 (ie before json type support in session variables), we expect every session
// variable to arrive as a string and then we parse that string into whatever type we need
let session_var_value = &session_var_value_wrapped
.as_str()
.ok_or(error::InternalDeveloperError::VariableJsonNotSupported {
session_variable: session_var_name.clone(),
})?
.to_string();
match &to_type.underlying_type {
QualifiedBaseType::Named(type_name) => {
match type_name {
@ -297,6 +316,7 @@ fn typecast_session_variable_v1(
InbuiltType::Int => {
let value: i32 = session_var_value.parse().map_err(|_| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "int".into(),
found: session_var_value.clone(),
}
@ -306,6 +326,7 @@ fn typecast_session_variable_v1(
InbuiltType::Float => {
let value: f32 = session_var_value.parse().map_err(|_| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "float".into(),
found: session_var_value.clone(),
}
@ -316,6 +337,7 @@ fn typecast_session_variable_v1(
"true" => Ok(serde_json::Value::Bool(true)),
"false" => Ok(serde_json::Value::Bool(false)),
_ => Err(error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "true or false".into(),
found: session_var_value.clone(),
})?,
@ -337,76 +359,107 @@ fn typecast_session_variable_v1(
}
}
}
QualifiedBaseType::List(_) => Err(error::InternalDeveloperError::VariableArrayTypeCast)?,
QualifiedBaseType::List(_) => Err(
error::InternalDeveloperError::VariableArrayTypeCastNotSupported {
session_variable: session_var_name.clone(),
},
)?,
}
}
fn typecast_session_variable_v2(
session_var_name: &SessionVariableName,
session_var_value: &SessionVariableValue,
to_type: &QualifiedTypeReference,
) -> Result<serde_json::Value, error::Error> {
let value = serde_json::from_str(&session_var_value.0)?;
typecheck_session_variable(&value, to_type)?;
Ok(value)
}
fn typecheck_session_variable(
value: &serde_json::Value,
to_type: &QualifiedTypeReference,
) -> Result<(), crate::Error> {
match &to_type.underlying_type {
QualifiedBaseType::Named(type_name) => match type_name {
QualifiedTypeName::Inbuilt(primitive) => match primitive {
InbuiltType::Int => {
if !value.is_i64() {
Err(error::InternalDeveloperError::VariableTypeCast {
let value: i64 = session_var_value.as_i64().ok_or_else(|| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "int".into(),
found: value.to_string(),
})?;
}
Ok(())
found: session_var_value.to_string(),
}
})?;
Ok(serde_json::Value::Number(value.into()))
}
InbuiltType::Float => {
if !value.is_f64() {
Err(error::InternalDeveloperError::VariableTypeCast {
let value: f64 = session_var_value.as_f64().ok_or_else(|| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "float".into(),
found: session_var_value.to_string(),
}
})?;
serde_json::to_value(value).map_err(|_error| {
// f64 may not cleanly go into JSON numbers if the value is a NaN or infinite
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "float".into(),
found: value.to_string(),
})?;
}
Ok(())
}
.into()
})
}
InbuiltType::Boolean => {
if !value.is_boolean() {
Err(error::InternalDeveloperError::VariableTypeCast {
let value: bool = session_var_value.as_bool().ok_or_else(|| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "true or false".into(),
found: value.to_string(),
})?;
}
Ok(())
found: session_var_value.to_string(),
}
})?;
Ok(serde_json::Value::Bool(value))
}
InbuiltType::ID | InbuiltType::String => {
if !value.is_string() {
Err(error::InternalDeveloperError::VariableTypeCast {
let value: &str = session_var_value.as_str().ok_or_else(|| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "string".into(),
found: value.to_string(),
})?;
}
Ok(())
found: session_var_value.to_string(),
}
})?;
Ok(serde_json::Value::String(value.to_string()))
}
},
QualifiedTypeName::Custom(_) => Ok(()),
QualifiedTypeName::Custom(_) => {
let value = session_var_value.as_value().map_err(|parse_error| {
error::InternalDeveloperError::VariableExpectedJson {
session_variable: session_var_name.clone(),
parse_error,
}
})?;
Ok(value)
}
},
QualifiedBaseType::List(element_type) => {
let value = session_var_value.as_value().map_err(|parse_error| {
error::InternalDeveloperError::VariableExpectedJson {
session_variable: session_var_name.clone(),
parse_error,
}
})?;
let elements = value.as_array().ok_or_else(|| {
error::InternalDeveloperError::VariableTypeCast {
session_variable: session_var_name.clone(),
expected: "array".into(),
found: value.to_string(),
}
})?;
for element in elements {
typecheck_session_variable(element, element_type)?;
}
Ok(())
let typecasted_elements = elements
.iter()
.map(|element| {
typecast_session_variable_v2(
session_var_name,
&SessionVariableValue::Parsed(element.clone()),
element_type,
)
})
.collect::<Result<Vec<_>, _>>()?;
Ok(serde_json::Value::Array(typecasted_elements))
}
}
}

View File

@ -100,7 +100,7 @@ machete:
# update golden tests
update-golden-files: start-docker-test-deps
UPDATE_GOLDENFILES=1 cargo test
UPDATE_GOLDENFILES=1 just test
just fix-format
update-custom-connector-schema-in-test-metadata: