use serde_json::Value as JsonValue;
use crate::{
db::types::JsonbValue,
error::{FraiseQLError, Result},
graphql::FieldSelection,
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) -> Option<JsonValue> {
if sel.arguments.is_empty() {
return None;
}
let mut map = serde_json::Map::with_capacity(sel.arguments.len());
for arg in &sel.arguments {
let value = serde_json::from_str::<JsonValue>(&arg.value_json)
.unwrap_or_else(|_| JsonValue::String(arg.value_json.clone()));
map.insert(arg.name.clone(), value);
}
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;
};
fields.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;
};
sel.nested_fields.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 {
fields
.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],
) -> Vec<GatedField> {
let Some(type_def) = schema.find_type(type_name) else {
return Vec::new();
};
fields
.iter()
.filter(|sel| type_def.fields.iter().any(|f| f.name.as_str() == sel.name && f.authorize))
.map(|sel| GatedField {
field_name: sel.name.clone(),
alias: sel.alias.clone(),
arguments: field_arguments_json(sel),
})
.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;
}
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(FieldAuthzDecision::Deny {
on_deny: FieldDenyPolicy::Mask,
..
}) => {
map.insert(projected_key, JsonValue::Null);
},
Ok(FieldAuthzDecision::Deny {
code,
on_deny: FieldDenyPolicy::Reject,
}) => {
return Err(field_authz_error(type_name, &gf.field_name, &code));
},
Err(_) => {
return Err(field_authz_error(
type_name,
&gf.field_name,
"field_authorization_failed",
));
},
}
}
Ok(())
}
#[cfg(test)]
mod tests;