use std::{collections::HashMap, sync::Arc};
use fraiseql_db::types::{ColumnSpec, ColumnValue};
use serde_json::Value as JsonValue;
use crate::{
backend::types::JsonbValue,
error::{FraiseQLError, Result},
graphql::FieldSelection,
runtime::projection::effective_selections,
schema::{CompiledSchema, FieldDenyPolicy, FieldType},
security::SecurityContext,
};
#[non_exhaustive]
pub struct FieldAuthzRequest<'a> {
pub principal: &'a SecurityContext,
pub type_name: &'a str,
pub field_name: &'a str,
pub parent: Option<&'a serde_json::Value>,
pub arguments: Option<&'a serde_json::Value>,
}
#[non_exhaustive]
pub enum FieldAuthzDecision {
Allow,
Deny {
code: String,
on_deny: FieldDenyPolicy,
},
}
pub trait FieldAuthorizer: Send + Sync {
fn authorize_field(&self, req: &FieldAuthzRequest<'_>) -> Result<FieldAuthzDecision>;
}
pub(crate) struct GatedField {
pub(crate) field_name: String,
pub(crate) alias: Option<String>,
pub(crate) arguments: Option<JsonValue>,
}
fn object_type_of(field_type: &FieldType) -> Option<&str> {
field_type
.type_name()
.or_else(|| field_type.inner_type().and_then(FieldType::type_name))
}
fn field_arguments_json(
sel: &FieldSelection,
variables: &HashMap<String, JsonValue>,
) -> Result<Option<JsonValue>> {
if sel.arguments.is_empty() {
return Ok(None);
}
let mut map = serde_json::Map::with_capacity(sel.arguments.len());
for arg in &sel.arguments {
map.insert(
arg.name.clone(),
crate::graphql::value_json::decode_resolved(&arg.value_json, variables)?,
);
}
Ok(Some(JsonValue::Object(map)))
}
pub(crate) fn selection_set_selects_gated_field(
schema: &CompiledSchema,
type_name: &str,
fields: &[FieldSelection],
) -> bool {
let Some(type_def) = schema.find_type(type_name) else {
return false;
};
effective_selections(fields, type_name, schema).iter().any(|sel| {
type_def.fields.iter().any(|f| f.name.as_str() == sel.name && f.authorize)
|| selection_field_has_gated_descendant(schema, type_name, sel)
})
}
fn selection_field_has_gated_descendant(
schema: &CompiledSchema,
parent_type: &str,
sel: &FieldSelection,
) -> bool {
if sel.nested_fields.is_empty() {
return false;
}
let Some(parent_def) = schema.find_type(parent_type) else {
return false;
};
let Some(field_def) = parent_def.fields.iter().find(|f| f.name.as_str() == sel.name) else {
return false;
};
let Some(child_type) = object_type_of(&field_def.field_type) else {
return false;
};
let Some(child_def) = schema.find_type(child_type) else {
return false;
};
effective_selections(&sel.nested_fields, child_type, schema)
.iter()
.any(|child_sel| {
child_def
.fields
.iter()
.any(|f| f.name.as_str() == child_sel.name && f.authorize)
|| selection_field_has_gated_descendant(schema, child_type, child_sel)
})
}
pub(crate) fn selection_set_has_nested_gated_field(
schema: &CompiledSchema,
type_name: &str,
fields: &[FieldSelection],
) -> bool {
effective_selections(fields, type_name, schema)
.iter()
.any(|sel| selection_field_has_gated_descendant(schema, type_name, sel))
}
pub(crate) fn collect_top_level_gated_fields(
schema: &CompiledSchema,
type_name: &str,
fields: &[FieldSelection],
variables: &HashMap<String, JsonValue>,
) -> Result<Vec<GatedField>> {
let Some(type_def) = schema.find_type(type_name) else {
return Ok(Vec::new());
};
effective_selections(fields, type_name, schema)
.into_iter()
.filter(|sel| type_def.fields.iter().any(|f| f.name.as_str() == sel.name && f.authorize))
.map(|sel| {
Ok(GatedField {
field_name: sel.name.clone(),
alias: sel.alias.clone(),
arguments: field_arguments_json(sel, variables)?,
})
})
.collect()
}
pub(crate) fn deny_if_gated_field_selected(
schema: &CompiledSchema,
type_name: &str,
fields: &[FieldSelection],
path: &str,
) -> Result<()> {
if selection_set_selects_gated_field(schema, type_name, fields) {
return Err(FraiseQLError::Authorization {
message: format!(
"Field-level authorization is not enforced on the {path} path; a policy-gated \
field on type '{type_name}' was selected"
),
action: Some("read".to_string()),
resource: Some(type_name.to_string()),
});
}
Ok(())
}
#[cfg(feature = "federation")]
pub(crate) fn deny_if_schema_has_gated_field(schema: &CompiledSchema, path: &str) -> Result<()> {
if schema.has_any_authorize_field() {
return Err(FraiseQLError::Authorization {
message: format!(
"Field-level authorization is not enforced on the {path} path, but the schema \
declares policy-gated fields"
),
action: Some("read".to_string()),
resource: None,
});
}
Ok(())
}
fn field_authz_error(type_name: &str, field: &str, code: &str) -> FraiseQLError {
FraiseQLError::Authorization {
message: format!("Access denied to field '{field}' on type '{type_name}' [{code}]"),
action: Some("read".to_string()),
resource: Some(format!("{type_name}.{field}")),
}
}
pub(crate) fn apply_field_authorizer(
pass: &FieldAuthzPass<'_>,
results: &[JsonbValue],
projected: &mut JsonValue,
returns_list: bool,
) -> Result<()> {
if pass.gated.is_empty() {
return Ok(());
}
match projected {
JsonValue::Array(rows) if returns_list => {
for (i, row) in rows.iter_mut().enumerate() {
let parent = results.get(i).map(JsonbValue::as_value);
enforce_row(pass, parent, row)?;
}
},
JsonValue::Object(_) if !returns_list => {
let parent = results.first().map(JsonbValue::as_value);
enforce_row(pass, parent, projected)?;
},
_ => {},
}
Ok(())
}
pub(crate) fn apply_field_authorizer_to_entity(
pass: &FieldAuthzPass<'_>,
parent: &JsonValue,
projected: &mut JsonValue,
) -> Result<()> {
if pass.gated.is_empty() {
return Ok(());
}
enforce_row(pass, Some(parent), projected)
}
pub(crate) struct FieldAuthzPass<'a> {
pub(crate) authorizer: &'a dyn FieldAuthorizer,
pub(crate) principal: &'a SecurityContext,
pub(crate) type_name: &'a str,
pub(crate) gated: &'a [GatedField],
pub(crate) statically_masked: &'a [String],
}
fn enforce_row(
pass: &FieldAuthzPass<'_>,
parent: Option<&JsonValue>,
projected_row: &mut JsonValue,
) -> Result<()> {
let JsonValue::Object(map) = projected_row else {
return Ok(());
};
let FieldAuthzPass {
authorizer,
principal,
type_name,
gated,
statically_masked,
} = *pass;
for gf in gated {
let projected_key = if map.contains_key(&gf.field_name) {
gf.field_name.clone()
} else if let Some(alias) = gf.alias.as_ref().filter(|a| map.contains_key(a.as_str())) {
alias.clone()
} else {
continue;
};
if statically_masked.iter().any(|m| m == &gf.field_name) {
continue;
}
if adjudicate_field(authorizer, principal, type_name, gf, parent)?
== FieldDisposition::Masked
{
map.insert(projected_key, JsonValue::Null);
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldDisposition {
Present,
Masked,
}
fn adjudicate_field(
authorizer: &dyn FieldAuthorizer,
principal: &SecurityContext,
type_name: &str,
gf: &GatedField,
parent: Option<&JsonValue>,
) -> Result<FieldDisposition> {
let req = FieldAuthzRequest {
principal,
type_name,
field_name: &gf.field_name,
parent,
arguments: gf.arguments.as_ref(),
};
match authorizer.authorize_field(&req) {
Ok(FieldAuthzDecision::Allow) => Ok(FieldDisposition::Present),
Ok(FieldAuthzDecision::Deny {
on_deny: FieldDenyPolicy::Mask,
..
}) => Ok(FieldDisposition::Masked),
Ok(FieldAuthzDecision::Deny {
code,
on_deny: FieldDenyPolicy::Reject,
}) => Err(field_authz_error(type_name, &gf.field_name, &code)),
Err(_) => Err(super::authorizer::policy_unavailable()),
}
}
pub(crate) struct RowFieldGate {
authorizer: Arc<dyn FieldAuthorizer>,
principal: SecurityContext,
type_name: String,
slots: Vec<(usize, GatedField)>,
}
impl RowFieldGate {
pub(crate) fn new(
authorizer: &Arc<dyn FieldAuthorizer>,
principal: &SecurityContext,
type_name: &str,
gated: Vec<GatedField>,
columns: &[ColumnSpec],
) -> Option<Self> {
let slots: Vec<(usize, GatedField)> = gated
.into_iter()
.filter_map(|gf| columns.iter().position(|c| c.name == gf.field_name).map(|i| (i, gf)))
.collect();
if slots.is_empty() {
return None;
}
Some(Self {
authorizer: Arc::clone(authorizer),
principal: principal.clone(),
type_name: type_name.to_string(),
slots,
})
}
pub(crate) fn adjudicate_row(
&self,
columns: &[ColumnSpec],
row: &mut [ColumnValue],
) -> Result<()> {
let parent = row_parent_json(columns, row);
for (slot, gf) in &self.slots {
if *slot >= row.len() {
continue;
}
if adjudicate_field(
self.authorizer.as_ref(),
&self.principal,
&self.type_name,
gf,
Some(&parent),
)? == FieldDisposition::Masked
{
row[*slot] = ColumnValue::Null;
}
}
Ok(())
}
}
fn row_parent_json(columns: &[ColumnSpec], row: &[ColumnValue]) -> JsonValue {
let mut map = serde_json::Map::with_capacity(columns.len());
for (spec, value) in columns.iter().zip(row.iter()) {
map.insert(spec.name.clone(), column_value_to_json(value));
}
JsonValue::Object(map)
}
fn column_value_to_json(value: &ColumnValue) -> JsonValue {
match value {
ColumnValue::Text(s)
| ColumnValue::Uuid(s)
| ColumnValue::Timestamptz(s)
| ColumnValue::Date(s) => JsonValue::String(s.clone()),
ColumnValue::Int32(i) => JsonValue::from(*i),
ColumnValue::Int64(i) => JsonValue::from(*i),
ColumnValue::Float64(f) => {
serde_json::Number::from_f64(*f).map_or(JsonValue::Null, JsonValue::Number)
},
ColumnValue::Boolean(b) => JsonValue::Bool(*b),
ColumnValue::Json(raw) => {
serde_json::from_str(raw).unwrap_or_else(|_| JsonValue::String(raw.clone()))
},
ColumnValue::Null => JsonValue::Null,
}
}
#[cfg(test)]
mod tests;