use super::{
query::QueryRunner,
query_nested::{LevelAuthz, Over, SelectionAccess},
query_params::{
apply_pagination_order, client_where_argument, coerce_pagination_arg,
combine_explicit_arg_where, enforce_max_offset, enforce_max_page_size,
inject_param_where_clause, nearest_order_and_limit,
},
query_projection::enrich_order_by_clauses,
};
use crate::{
backend::{ComposedEmbed, OrderByClause, WhereClause},
error::{FraiseQLError, Result},
runtime::{
executor::context::ExecutorContext, field_filter::FieldAccessResult, matcher::QueryMatch,
},
schema::{CompiledSchema, QueryDefinition, SqlProjectionHint},
security::{
RLSPolicy, SecurityContext,
authorizer::{AuthzNesting, AuthzOperation, OperationKind, enforce_authz},
rls_policy::RlsTarget,
},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum GatedFieldHandling {
RefuseAsUnsupported,
AdjudicatePerRow,
}
#[derive(Debug, Clone, Copy)]
pub(super) enum DirectReadCost<'a> {
Flat(Option<&'a crate::security::RequestBudget>),
Composed,
}
#[derive(Clone, Copy)]
pub(super) enum Door<'a> {
Document,
Direct {
gated: GatedFieldHandling,
cost: DirectReadCost<'a>,
nesting: Option<&'a AuthzNesting>,
},
Count,
}
impl Door<'_> {
const fn pages_unconditionally(self) -> bool {
matches!(self, Self::Direct { .. })
}
const fn plans_nested_levels(self) -> bool {
matches!(
self,
Self::Document
| Self::Direct {
cost: DirectReadCost::Flat(_),
..
}
)
}
}
pub(in super::super) enum Ask {
Now(AuthzOperation),
AtDispatch,
}
pub(in super::super) struct OperationGate<'a> {
ctx: &'a ExecutorContext,
}
impl<'a> OperationGate<'a> {
pub(in super::super) const fn new(ctx: &'a ExecutorContext) -> Self {
Self { ctx }
}
pub(in super::super) fn check(
&self,
ask: Ask,
query: &QueryDefinition,
principal: Option<&SecurityContext>,
input: Option<&serde_json::Value>,
) -> Result<()> {
if let (Ask::Now(op), Some(authorizer)) = (ask, self.ctx.config.authorizer.as_ref()) {
enforce_authz(authorizer.as_ref(), principal, &[op], input)?;
}
if principal.is_some_and(SecurityContext::is_expired) {
return Err(FraiseQLError::Validation {
message: "Security token has expired".to_string(),
path: Some("request.authorization".to_string()),
});
}
crate::security::role_gate::enforce_requires_role(
"Query",
&query.name,
query.requires_role.as_deref(),
principal,
)?;
crate::security::actor_type::enforce_requires_actor(
"Query",
&query.name,
&query.requires_actor,
principal,
)?;
let schema = &self.ctx.schema;
for member in schema.possible_types(query.return_type.as_str()).unwrap_or_default() {
let read = super::query_nested::own_read(schema, member);
let role = read
.and_then(|q| q.requires_role.as_deref())
.or_else(|| schema.find_type(member).and_then(|t| t.requires_role.as_deref()));
crate::security::role_gate::enforce_requires_role(
"Query",
&query.name,
role,
principal,
)?;
if let Some(read) = read {
crate::security::actor_type::enforce_requires_actor(
"Query",
&query.name,
&read.requires_actor,
principal,
)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub(in super::super) enum RowScope {
Scoped(Option<WhereClause>),
Hidden(HiddenBy),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in super::super) enum HiddenBy {
Policy,
InjectParams,
}
impl RowScope {
pub(in super::super) fn or_refuse(
self,
query: &str,
target: &str,
) -> Result<Option<WhereClause>> {
match self {
Self::Scoped(clause) => Ok(clause),
Self::Hidden(HiddenBy::Policy) => Err(FraiseQLError::Validation {
message: format!("Query '{query}' not found in schema"),
path: None,
}),
Self::Hidden(HiddenBy::InjectParams) => Err(HiddenBy::inject_refusal(query, target)),
}
}
}
impl HiddenBy {
fn inject_refusal(query: &str, target: &str) -> FraiseQLError {
FraiseQLError::Validation {
message: format!(
"Type '{target}' is scoped by the inject params of query '{query}', which a \
request without a security context cannot resolve"
),
path: None,
}
}
}
pub(in super::super) fn row_predicate(
schema: &CompiledSchema,
policy: Option<&dyn RLSPolicy>,
read: &QueryDefinition,
target: &str,
over: Over,
principal: Option<&SecurityContext>,
) -> Result<RowScope> {
if principal.is_none() && !read.inject_params.is_empty() {
return Ok(RowScope::Hidden(HiddenBy::InjectParams));
}
let mut conditions = Vec::with_capacity(read.inject_params.len() + 1);
match (policy, principal) {
(Some(policy), Some(principal)) => {
if let Some(clause) =
policy.evaluate(principal, &RlsTarget::query(&read.name, target))?
{
conditions.push(clause.into_where_clause());
}
},
(Some(_), None) => return Ok(RowScope::Hidden(HiddenBy::Policy)),
(None, _) => {},
}
if let (false, true, Some(principal)) =
(read.inject_params.is_empty(), read.sql_source_kind.is_relation(), principal)
{
let in_documents = std::collections::HashMap::new();
let native_columns = match over {
Over::Rows => &read.native_columns,
Over::Documents => &in_documents,
};
for (column, source) in &read.inject_params {
let value = super::super::resolve_inject_value(
column,
source,
principal,
schema.tenant_claim(),
)?;
conditions.push(inject_param_where_clause(column, value, native_columns));
}
}
Ok(RowScope::Scoped(and_all(conditions)))
}
fn function_source_args(
query: &QueryDefinition,
provided: &std::collections::HashMap<String, serde_json::Value>,
principal: Option<&SecurityContext>,
tenant_claim: &str,
) -> Result<Vec<serde_json::Value>> {
let mut args = Vec::with_capacity(query.function_arity());
args.extend(query.function_arguments().map(|arg| {
provided.get(&arg.name).cloned().unwrap_or_else(|| {
arg.default_value.as_ref().map_or(serde_json::Value::Null, |v| v.to_json())
})
}));
if !query.inject_params.is_empty() {
let principal =
principal.ok_or_else(|| HiddenBy::inject_refusal(&query.name, &query.return_type))?;
for (column, source) in &query.inject_params {
args.push(super::super::resolve_inject_value(column, source, principal, tenant_claim)?);
}
}
Ok(args)
}
pub(super) fn refuse_function_root(query: &str, what: &str) -> FraiseQLError {
FraiseQLError::Unsupported {
message: format!(
"Query '{query}' reads a set-returning function, which cannot serve {what}: only \
the rows of its call are read"
),
}
}
pub(super) fn and_all(mut conditions: Vec<WhereClause>) -> Option<WhereClause> {
match conditions.len() {
0 => None,
1 => conditions.pop(),
_ => Some(WhereClause::And(conditions)),
}
}
pub(in super::super) enum RootRoute {
Function,
Relay,
Count,
Rows(Box<RootRead>),
}
pub(in super::super) struct RootRead {
pub(super) sql_source: String,
pub(super) source_args: Option<Vec<serde_json::Value>>,
pub(super) composed_where: Option<WhereClause>,
pub(super) order_by: Option<Vec<OrderByClause>>,
pub(super) limit: Option<u32>,
pub(super) offset: Option<u32>,
pub(super) session_vars: Vec<(String, String)>,
pub(super) leaf_fields: usize,
pub(super) selection: SelectionAccess,
pub(super) projection: Option<std::sync::Arc<SqlProjectionHint>>,
pub(super) built_in_sql: bool,
pub(super) nested: Vec<ComposedEmbed>,
pub(super) gated_present: bool,
pub(super) abstract_read: bool,
pub(super) response_bytes: Option<u64>,
pub(super) nearest_k: Option<u32>,
pub(super) nearest_count: Option<u32>,
pub(super) decimals: Option<std::sync::Arc<crate::backend::traits::DecimalShape>>,
}
impl RootRead {
pub(super) const fn access(&self) -> &FieldAccessResult {
&self.selection.root
}
pub(super) fn session_pairs(&self) -> Vec<(&str, &str)> {
self.session_vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
}
pub(super) const fn budget(&self) -> Option<crate::security::ResponseBudget> {
crate::security::ResponseBudget::new(self.response_bytes)
}
pub(super) fn projection_request(&self) -> crate::backend::ProjectionRequest<'_> {
crate::backend::ProjectionRequest {
view: &self.sql_source,
source_args: self.source_args.as_deref(),
projection: self.projection.as_deref(),
where_clause: self.composed_where.as_ref(),
order_by: self.order_by.as_deref(),
limit: self.limit,
offset: self.offset,
matched_up_to: self.nearest_count,
decimals: self.decimals.as_deref(),
}
}
pub(super) fn composed_level(&mut self) -> crate::backend::ComposedLevel {
let embeds = std::mem::take(&mut self.nested);
crate::backend::ComposedLevel {
view: self.sql_source.clone(),
projection: self.projection.as_ref().map(|h| h.projection_template.clone()),
where_clause: self.composed_where.clone(),
order_by: self.order_by.clone(),
limit: self.limit,
offset: self.offset,
keyset: None,
keys: super::query_nested::root_keys(&embeds),
embeds,
decimals: self.decimals.clone(),
}
}
}
impl QueryRunner {
pub(super) fn resolve_root_read(
&self,
query_match: &QueryMatch,
variables: Option<&serde_json::Value>,
principal: Option<&SecurityContext>,
door: Door<'_>,
) -> Result<RootRoute> {
let query_def = &query_match.query_def;
let schema = &self.ctx.schema;
let root_fields =
query_match.selections.first().map_or(&[][..], |r| r.nested_fields.as_slice());
let ask = match door {
Door::Document => Ask::AtDispatch,
Door::Direct {
nesting: Some(nesting),
..
} => Ask::Now(AuthzOperation::nested(
&query_def.name,
&query_def.return_type,
nesting.clone(),
)),
Door::Direct { .. } | Door::Count => Ask::Now(AuthzOperation::root(
OperationKind::Query,
&query_def.name,
Some(query_def.return_type.as_str()),
)),
};
OperationGate::new(&self.ctx).check(ask, query_def, principal, variables)?;
if !query_def.sql_source_kind.is_relation() {
let refused = match door {
Door::Count => Some("a count"),
Door::Direct {
cost: DirectReadCost::Composed,
..
} => Some("an embedded level"),
Door::Direct {
gated: GatedFieldHandling::AdjudicatePerRow,
..
} => Some("a row-shaped read"),
Door::Document if query_def.relay => Some("a Relay connection"),
Door::Document | Door::Direct { .. } => None,
};
if let Some(what) = refused {
return Err(refuse_function_root(&query_def.name, what));
}
}
if matches!(door, Door::Document) {
if query_def.function.is_some() {
return Ok(RootRoute::Function);
}
if query_def.relay {
return Ok(RootRoute::Relay);
}
if query_def.returns_count {
return Ok(RootRoute::Count);
}
}
let gated_present = match door {
Door::Document if principal.is_some() => {
crate::security::field_authorizer::selection_set_selects_gated_field(
schema,
&query_def.return_type,
root_fields,
)
},
Door::Document => {
crate::security::field_authorizer::deny_if_gated_field_selected(
schema,
&query_def.return_type,
root_fields,
"unauthenticated query",
)?;
false
},
Door::Direct {
gated: GatedFieldHandling::RefuseAsUnsupported,
..
} => {
crate::security::field_authorizer::deny_if_gated_field_selected(
schema,
&query_def.return_type,
root_fields,
"REST",
)?;
false
},
Door::Direct {
gated: GatedFieldHandling::AdjudicatePerRow,
..
}
| Door::Count => false,
};
let abstract_read = match door {
Door::Document => super::query_abstract::AbstractRead::of(schema, query_def),
Door::Direct { .. } | Door::Count => None,
};
if let Some(read) = &abstract_read {
read.refuse_gated_fields(schema, root_fields)?;
}
let row_scope = row_predicate(
schema,
self.ctx.config.rls_policy.as_deref(),
query_def,
&query_def.return_type,
Over::Rows,
principal,
)?
.or_refuse(&query_def.name, &query_def.return_type)?;
let sql_source =
query_def.sql_source.as_ref().ok_or_else(|| FraiseQLError::Validation {
message: "Query has no SQL source".to_string(),
path: None,
})?;
let plan = match door {
Door::Count => None,
Door::Document | Door::Direct { .. } => Some(self.ctx.planner.projection(query_match)),
};
let authz = LevelAuthz::from_config(&self.ctx.config, variables);
let selection = match (&abstract_read, &plan) {
(Some(read), _) => read.classify(schema, root_fields, principal, authz)?,
(None, Some(plan)) => SelectionAccess::classify(
schema,
&query_def.return_type,
root_fields,
plan.projection_fields.clone(),
principal,
authz,
)?,
(None, None) => SelectionAccess::classify(
schema,
&query_def.return_type,
&[],
Vec::new(),
principal,
authz,
)?,
};
let nested = if door.plans_nested_levels() {
self.plan_nested_reads(&query_def.return_type, root_fields, principal, &selection)?
} else {
Vec::new()
};
if !nested.is_empty() && !query_def.sql_source_kind.is_relation() {
return Err(refuse_function_root(
&query_def.name,
"a composed read of its nested levels",
));
}
let scoped = self.and_scope_where(row_scope, query_match)?;
let client = client_where_argument(
schema,
query_def,
&query_match.arguments,
self.ctx.config.rls_policy.as_deref(),
principal,
self.ctx.config.suggestions,
)?;
let scoped_and_filtered = and_all(scoped.into_iter().chain(client).collect());
let (composed_where, source_args) = if query_def.sql_source_kind.is_relation() {
let composed_where = combine_explicit_arg_where(
scoped_and_filtered,
&query_def.arguments,
&query_match.arguments,
&query_def.native_columns,
);
(composed_where, None)
} else {
let args = function_source_args(
query_def,
&query_match.arguments,
principal,
schema.tenant_claim(),
)?;
(scoped_and_filtered, Some(args))
};
let session_vars = self.resolve_session_vars(principal)?;
let Some(plan) = plan else {
return Ok(RootRoute::Rows(Box::new(RootRead {
sql_source: sql_source.clone(),
source_args,
composed_where,
order_by: None,
limit: None,
offset: None,
session_vars,
leaf_fields: 0,
selection,
projection: None,
built_in_sql: false,
nested,
gated_present,
abstract_read: false,
response_bytes: None,
nearest_k: None,
nearest_count: None,
decimals: self.ctx.decimals.read_by(&query_def.name).cloned(),
})));
};
let pages = |published: bool| door.pages_unconditionally() || published;
let limit = enforce_max_page_size(
if pages(query_def.auto_params.has_limit) {
coerce_pagination_arg("limit", query_match.arguments.get("limit"))?
} else {
None
},
self.ctx.config.max_page_size,
"limit",
)?;
let offset = enforce_max_offset(
if pages(query_def.auto_params.has_offset) {
coerce_pagination_arg("offset", query_match.arguments.get("offset"))?
} else {
None
},
self.ctx.config.max_offset,
"offset",
)?;
let order_by = if pages(query_def.auto_params.has_order_by) {
query_match
.arguments
.get("orderBy")
.map(OrderByClause::from_graphql_json)
.transpose()?
.map(|clauses| {
enrich_order_by_clauses(
clauses,
schema,
&query_def.return_type,
&query_def.native_columns,
principal,
self.ctx.config.suggestions,
)
})
.transpose()?
} else {
None
};
let (limit, order_by, nearest_k) =
match nearest_order_and_limit(&query_match.arguments, schema, query_def, principal)? {
Some((clause, k)) => (
enforce_max_page_size(Some(k), self.ctx.config.max_page_size, "nearest.k")?,
Some(vec![clause]),
Some(k),
),
None => (limit, order_by, None),
};
if let Door::Direct {
cost: DirectReadCost::Flat(request_budget),
..
} = door
{
self.charge_direct_read_cost(
&query_def.name,
&crate::graphql::DirectReadProjection::flat(plan.projection_fields.len(), limit),
request_budget,
)?;
}
let order_by = match (order_by, query_match.search_relevance.as_ref()) {
(None, Some(relevance)) => Some(vec![OrderByClause::by_relevance(relevance.clone())]),
(existing, _) => existing,
};
let full_row = match door {
Door::Document => gated_present || !nested.is_empty() || abstract_read.is_some(),
Door::Direct { .. } | Door::Count => true,
};
let projection = self.build_projection_hint(
query_match,
&plan,
full_row,
order_by.as_ref().and_then(|c| c.first()),
selection.masked_set(),
)?;
let built_in_sql = projection.as_ref().is_some_and(|kept| kept.built);
let decimals = match &projection {
Some(kept) if kept.decimals_as_text => None,
_ => self.ctx.decimals.read_by(&query_def.name).cloned(),
};
let projection = projection.map(|kept| kept.hint);
let order_by = apply_pagination_order(order_by, query_def, limit, offset);
let nearest_count = if nested.is_empty() {
self.nearest_count_bound(nearest_k)
} else {
None
};
Ok(RootRoute::Rows(Box::new(RootRead {
sql_source: sql_source.clone(),
source_args,
composed_where,
order_by,
limit,
offset,
session_vars,
leaf_fields: plan.projection_fields.len(),
selection,
projection,
built_in_sql,
nested,
gated_present,
abstract_read: abstract_read.is_some(),
response_bytes: self.ctx.config.max_response_bytes,
nearest_k,
nearest_count,
decimals,
})))
}
pub(super) fn resolve_rows(
&self,
query_match: &QueryMatch,
variables: Option<&serde_json::Value>,
principal: Option<&SecurityContext>,
door: Door<'_>,
) -> Result<RootRead> {
match self.resolve_root_read(query_match, variables, principal, door)? {
RootRoute::Rows(read) => Ok(*read),
RootRoute::Function | RootRoute::Relay | RootRoute::Count => {
Err(FraiseQLError::Internal {
message: format!(
"the read of '{}' was routed away from its rows on a door that only \
reads rows — this is a bug",
query_match.query_def.name
),
source: None,
})
},
}
}
}
#[cfg(test)]
#[path = "function_root_tests.rs"]
mod function_root_tests;
#[cfg(test)]
#[path = "decimal_text_tests.rs"]
mod decimal_text_tests;
#[cfg(test)]
#[path = "projection_mask_tests.rs"]
mod projection_mask_tests;