use super::{Executor, support};
use crate::{
error::{FraiseQLError, Result},
runtime::ExecutionContext,
security::{FieldAccessError, SecurityContext},
};
pub fn resolve_session_variables(
schema: &crate::schema::CompiledSchema,
security_context: &SecurityContext,
) -> crate::error::Result<Vec<(String, String)>> {
support::security::resolve_session_variables(
&schema.session_variables,
security_context,
schema.tenant_claim(),
support::security::SessionPurpose::Write,
)
}
impl Executor {
pub(super) fn validate_field_access(
&self,
query: &str,
variables: Option<&serde_json::Value>,
user_scopes: &[String],
filter: &crate::security::FieldFilter,
) -> Result<()> {
let query_match = self.ctx.matcher.match_query(query, variables)?;
let type_name = &query_match.query_def.return_type;
let field_refs: Vec<&str> = query_match.fields.iter().map(String::as_str).collect();
let errors = filter.validate_fields(type_name, &field_refs, user_scopes);
if errors.is_empty() {
Ok(())
} else {
let first_error = &errors[0];
Err(FraiseQLError::Authorization {
message: first_error.message.clone(),
action: Some("read".to_string()),
resource: Some(format!("{}.{}", first_error.type_name, first_error.field_name)),
})
}
}
pub async fn execute_with_context(
&self,
query: &str,
variables: Option<&serde_json::Value>,
ctx: &ExecutionContext,
) -> Result<serde_json::Value> {
if ctx.is_cancelled() {
return Err(FraiseQLError::cancelled(
ctx.query_id().to_string(),
"Query cancelled before execution".to_string(),
));
}
let token = ctx.cancellation_token().clone();
tokio::select! {
result = self.execute(query, variables) => {
result
}
() = token.cancelled() => {
Err(FraiseQLError::cancelled(
ctx.query_id().to_string(),
"Query cancelled during execution".to_string(),
))
}
}
}
pub async fn execute_with_security(
&self,
query: &str,
variables: Option<&serde_json::Value>,
security_context: &SecurityContext,
) -> Result<serde_json::Value> {
self.execute_with_timeout(query, variables, Some(security_context), None).await
}
pub async fn execute_operation_with_security(
&self,
query: &str,
variables: Option<&serde_json::Value>,
security_context: &SecurityContext,
operation_name: Option<&str>,
) -> Result<serde_json::Value> {
self.execute_with_timeout(
query,
variables,
Some(security_context),
super::execution::normalize_operation_name(operation_name),
)
.await
}
pub fn check_field_access(
&self,
type_name: &str,
field_name: &str,
user_scopes: &[String],
) -> std::result::Result<(), FieldAccessError> {
if let Some(ref filter) = self.ctx.config.field_filter {
filter.can_access(type_name, field_name, user_scopes)
} else {
Ok(())
}
}
#[deprecated(
since = "2.2.0",
note = "use execute() directly — it now returns Value"
)]
pub async fn execute_json(
&self,
query: &str,
variables: Option<&serde_json::Value>,
) -> Result<serde_json::Value> {
self.execute(query, variables).await
}
}
#[cfg(test)]
mod session_variable_tests {
#![allow(clippy::unwrap_used)]
use chrono::Utc;
use crate::{
error::Result,
schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
security::SecurityContext,
};
fn resolve_session_variables(
config: &SessionVariablesConfig,
ctx: &SecurityContext,
) -> Result<Vec<(String, String)>> {
super::support::security::resolve_session_variables(
config,
ctx,
crate::schema::security_config::DEFAULT_TENANT_CLAIM,
super::support::security::SessionPurpose::Write,
)
}
fn make_context() -> SecurityContext {
let mut attributes = std::collections::HashMap::new();
attributes.insert("tenant_id".to_string(), serde_json::json!("tenant-abc"));
attributes.insert("x-tenant-id".to_string(), serde_json::json!("header-tenant"));
attributes.insert("region".to_string(), serde_json::json!("eu-west-1"));
SecurityContext {
user_id: crate::types::UserId::new("user-42"),
roles: vec!["admin".to_string()],
tenant_id: Some(crate::types::TenantId::new("tenant-123")),
scopes: vec![],
attributes,
request_id: "req-test".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now(),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
#[test]
fn resolve_session_variables_jwt_claim() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.tenant_id".to_string(),
source: SessionVariableSource::Jwt {
claim: "tenant_id".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "app.tenant_id");
assert_eq!(vars[0].1, "tenant-abc");
}
#[test]
fn a_jwt_session_variable_never_reads_the_tenant_under_another_claim_name() {
let ctx = make_context(); let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.org".to_string(),
source: SessionVariableSource::Jwt {
claim: "org_id".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert!(vars.is_empty(), "an absent claim sets nothing, got {vars:?}");
}
#[test]
fn resolve_session_variables_jwt_well_known_sub() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.user_id".to_string(),
source: SessionVariableSource::Jwt {
claim: "sub".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "app.user_id");
assert_eq!(vars[0].1, "user-42");
}
#[test]
fn resolve_session_variables_literal() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.locale".to_string(),
source: SessionVariableSource::Literal {
value: "en".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "app.locale");
assert_eq!(vars[0].1, "en");
}
#[test]
fn inject_started_at_prepended() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.locale".to_string(),
source: SessionVariableSource::Literal {
value: "en".to_string(),
},
}],
inject_started_at: true,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 2);
assert_eq!(vars[0].0, fraiseql_db::STARTED_AT_VAR);
assert_eq!(vars[0].1, fraiseql_db::CLOCK_TIMESTAMP_DIRECTIVE);
assert_eq!(vars[1].0, "app.locale");
}
#[test]
fn inject_started_at_disabled() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert!(vars.is_empty());
assert!(!vars.iter().any(|(k, _)| k == "fraiseql.started_at"));
}
#[test]
fn resolve_session_variables_header() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.tenant".to_string(),
source: SessionVariableSource::Header {
header: "x-tenant-id".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "app.tenant");
assert_eq!(vars[0].1, "header-tenant");
}
#[test]
fn resolve_session_variables_jwt_email() {
let mut ctx = make_context();
ctx.email = Some("user@corp.com".to_string());
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.email".to_string(),
source: SessionVariableSource::Jwt {
claim: "email".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "app.email");
assert_eq!(vars[0].1, "user@corp.com");
}
#[test]
fn resolve_session_variables_jwt_display_name() {
let mut ctx = make_context();
ctx.display_name = Some("Jane Doe".to_string());
let config = SessionVariablesConfig {
variables: vec![
SessionVariableMapping {
name: "app.name".to_string(),
source: SessionVariableSource::Jwt {
claim: "name".to_string(),
},
},
SessionVariableMapping {
name: "app.display_name".to_string(),
source: SessionVariableSource::Jwt {
claim: "display_name".to_string(),
},
},
],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 2);
assert_eq!(vars[0].1, "Jane Doe");
assert_eq!(vars[1].1, "Jane Doe");
}
#[test]
fn resolve_session_variables_missing_email_skipped() {
let ctx = make_context(); let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.email".to_string(),
source: SessionVariableSource::Jwt {
claim: "email".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert!(vars.is_empty(), "missing email should be silently skipped");
}
#[test]
fn resolve_session_variables_enrichment_reads_namespace() {
let mut ctx = make_context();
ctx.attributes
.insert("fraiseql.enriched.actor_role".to_string(), serde_json::json!("manager"));
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.actor_role".to_string(),
source: SessionVariableSource::Enrichment {
field: "actor_role".to_string(),
},
}],
inject_started_at: false,
};
let vars = resolve_session_variables(&config, &ctx).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].0, "app.actor_role");
assert_eq!(vars[0].1, "manager");
}
#[test]
fn resolve_session_variables_enrichment_missing_field_errors() {
let ctx = make_context();
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.actor_role".to_string(),
source: SessionVariableSource::Enrichment {
field: "actor_role".to_string(),
},
}],
inject_started_at: false,
};
assert!(resolve_session_variables(&config, &ctx).is_err());
}
#[test]
fn resolve_session_variables_enrichment_does_not_fall_back_to_raw_claim() {
let mut ctx = make_context();
ctx.attributes.insert("actor_role".to_string(), serde_json::json!("admin"));
let config = SessionVariablesConfig {
variables: vec![SessionVariableMapping {
name: "app.actor_role".to_string(),
source: SessionVariableSource::Enrichment {
field: "actor_role".to_string(),
},
}],
inject_started_at: false,
};
assert!(
resolve_session_variables(&config, &ctx).is_err(),
"Enrichment must not fall back to a raw JWT claim"
);
}
}
#[cfg(test)]
mod enrichment_entry_point_tests {
#![allow(clippy::unwrap_used)]
use std::sync::Arc;
use chrono::Utc;
use super::super::mutation::any_write_selections;
use crate::{
error::FraiseQLError,
runtime::{Executor, executor::test_support::MockAdapter},
schema::{
CompiledSchema, MutationDefinition, QueryDefinition, SessionVariableMapping,
SessionVariableSource, TypeDefinition,
},
security::{EnrichmentMark, SecurityContext},
};
fn schema() -> CompiledSchema {
let mut schema = CompiledSchema::default();
schema.session_variables.variables.push(SessionVariableMapping {
name: "app.actor_id".to_string(),
source: SessionVariableSource::Enrichment {
field: "actor_id".to_string(),
},
});
let mut order = TypeDefinition::new("Order", "v_order");
order.fields = vec![crate::schema::FieldDefinition::new(
"id",
crate::schema::FieldType::Id,
)];
schema.types.push(order);
let mut query = QueryDefinition::new("orders", "Order");
query.sql_source = Some("v_order".to_string());
query.returns_list = true;
schema.queries.push(query);
let mut create = MutationDefinition::new("createOrder", "Order");
create.sql_source = Some("fn_create_order".to_string());
schema.mutations.push(create);
schema.build_indexes();
schema
}
fn executor() -> Executor {
Executor::new(schema(), Arc::new(MockAdapter::new(vec![])))
}
fn unresolved() -> SecurityContext {
SecurityContext {
user_id: crate::types::UserId::new("user-1336"),
roles: vec![],
tenant_id: None,
scopes: vec![],
attributes: std::collections::HashMap::new(),
request_id: "req-1336".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn resolved() -> SecurityContext {
let mut ctx = unresolved();
ctx.mark_enrichment(EnrichmentMark::Resolved);
ctx
}
fn is_refusal(err: &FraiseQLError) -> bool {
matches!(err, FraiseQLError::Authorization { .. })
}
#[tokio::test]
async fn the_graphql_document_entry_refuses_an_unresolved_principal() {
let err = executor()
.execute_with_security("{ orders { id } }", None, &unresolved())
.await
.expect_err("an unresolved principal must not execute");
assert!(is_refusal(&err), "expected an authorization refusal, got: {err}");
}
#[tokio::test]
async fn the_graphql_document_entry_admits_a_resolved_principal() {
let outcome =
executor().execute_with_security("{ orders { id } }", None, &resolved()).await;
assert!(
outcome.as_ref().err().is_none_or(|e| !is_refusal(e)),
"a resolved principal must get past this guard; got: {outcome:?}"
);
}
#[tokio::test]
async fn the_direct_read_entries_refuse_an_unresolved_principal() {
let executor = executor();
let query_match = crate::runtime::QueryMatcher::new(schema())
.match_query("{ orders { id } }", None)
.expect("the fixture query matches");
let read = executor
.execute_query_direct(&query_match, None, Some(&unresolved()), None)
.await;
assert!(
read.as_ref().err().is_some_and(is_refusal),
"execute_query_direct must refuse; got: {read:?}"
);
let count = executor.count_rows(&query_match, None, Some(&unresolved())).await;
assert!(
count.as_ref().err().is_some_and(is_refusal),
"count_rows must refuse too — it is the second chokepoint every REST read \
passes through, and a guard on one of the pair leaves the other open; \
got: {count:?}"
);
}
#[tokio::test]
async fn the_streaming_read_entry_refuses_an_unresolved_principal() {
let executor = executor();
let query_match = crate::runtime::QueryMatcher::new(schema())
.match_query("{ orders { id } }", None)
.expect("the fixture query matches");
let stream = executor.stream_query_direct(query_match, None, Some(unresolved())).await;
assert!(
stream.as_ref().err().is_some_and(is_refusal),
"stream_query_direct must refuse an unresolved principal too; got an Ok or a \
non-authorization error"
);
}
#[tokio::test]
async fn the_mutation_chokepoint_refuses_an_unresolved_principal() {
let err = executor()
.execute_mutation_as("createOrder", None, Some(&unresolved()), any_write_selections())
.await
.expect_err("an unresolved principal must not write");
assert!(is_refusal(&err), "expected an authorization refusal, got: {err}");
}
}