use modkit_security::{AccessScope, ScopeConstraint, ScopeFilter, ScopeValue};
use crate::constraints::{Constraint, Predicate};
use crate::models::EvaluationResponse;
#[derive(Debug, thiserror::Error)]
pub enum ConstraintCompileError {
#[error("constraints required but PDP returned none (fail-closed)")]
ConstraintsRequiredButAbsent,
#[error("all constraints failed compilation (fail-closed): {reason}")]
AllConstraintsFailed { reason: String },
}
pub fn compile_to_access_scope(
response: &EvaluationResponse,
require_constraints: bool,
supported_properties: &[&str],
) -> Result<AccessScope, ConstraintCompileError> {
if response.context.constraints.is_empty() {
if require_constraints {
return Err(ConstraintCompileError::ConstraintsRequiredButAbsent);
}
return Ok(AccessScope::allow_all());
}
let mut constraints = Vec::new();
let mut fail_reasons: Vec<String> = Vec::new();
for constraint in &response.context.constraints {
match compile_constraint(constraint, supported_properties) {
Ok(sc) => constraints.push(sc),
Err(reason) => {
tracing::warn!(
reason = %reason,
"constraint compilation failed (fail-closed), possible PDP contract violation",
);
fail_reasons.push(reason);
}
}
}
if constraints.is_empty() {
return Err(ConstraintCompileError::AllConstraintsFailed {
reason: fail_reasons.join("; "),
});
}
if constraints.iter().all(ScopeConstraint::is_empty) {
return Ok(AccessScope::allow_all());
}
Ok(AccessScope::from_constraints(constraints))
}
fn compile_constraint(
constraint: &Constraint,
supported_properties: &[&str],
) -> Result<ScopeConstraint, String> {
let mut filters = Vec::new();
for predicate in &constraint.predicates {
let (property, filter) = match predicate {
Predicate::Eq(eq) => {
let value = json_to_scope_value(&eq.value)?;
(eq.property.as_str(), ScopeFilter::eq(&eq.property, value))
}
Predicate::In(p) => {
let values: Vec<ScopeValue> = p
.values
.iter()
.map(json_to_scope_value)
.collect::<Result<_, _>>()?;
if values.is_empty() {
return Err(format!(
"In predicate on '{}' has empty value list (fail-closed)",
p.property
));
}
(p.property.as_str(), ScopeFilter::r#in(&p.property, values))
}
Predicate::InGroup(p) => {
let group_ids: Vec<ScopeValue> = p
.group_ids
.iter()
.map(json_to_scope_value)
.collect::<Result<_, _>>()?;
if group_ids.is_empty() {
return Err(format!(
"InGroup predicate on '{}' has empty group_ids (fail-closed)",
p.property
));
}
(
p.property.as_str(),
ScopeFilter::in_group(&p.property, group_ids),
)
}
Predicate::InGroupSubtree(p) => {
let ancestor_ids: Vec<ScopeValue> = p
.ancestor_ids
.iter()
.map(json_to_scope_value)
.collect::<Result<_, _>>()?;
if ancestor_ids.is_empty() {
return Err(format!(
"InGroupSubtree predicate on '{}' has empty ancestor_ids (fail-closed)",
p.property
));
}
(
p.property.as_str(),
ScopeFilter::in_group_subtree(&p.property, ancestor_ids),
)
}
};
if !supported_properties.contains(&property) {
return Err(format!("unsupported property: {property}"));
}
filters.push(filter);
}
Ok(ScopeConstraint::new(filters))
}
fn json_to_scope_value(v: &serde_json::Value) -> Result<ScopeValue, String> {
match v {
serde_json::Value::String(s) => {
if let Ok(uuid) = uuid::Uuid::parse_str(s) {
Ok(ScopeValue::Uuid(uuid))
} else {
Ok(ScopeValue::String(s.clone()))
}
}
serde_json::Value::Number(n) => n.as_i64().map(ScopeValue::Int).ok_or_else(|| {
format!("only integer JSON numbers are supported for scope filters, got: {n}")
}),
serde_json::Value::Bool(b) => Ok(ScopeValue::Bool(*b)),
other => Err(format!(
"unsupported JSON value type for scope filter: {other}"
)),
}
}
#[cfg(test)]
#[path = "compiler_tests.rs"]
mod compiler_tests;