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<_, _>>()?;
(p.property.as_str(), ScopeFilter::r#in(&p.property, values))
}
};
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)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use crate::constraints::{EqPredicate, InPredicate};
use crate::models::EvaluationResponseContext;
use modkit_security::pep_properties;
use serde_json::json;
use uuid::Uuid;
fn uuid(s: &str) -> Uuid {
Uuid::parse_str(s).unwrap()
}
fn jid(s: &str) -> serde_json::Value {
json!(s)
}
const T1: &str = "11111111-1111-1111-1111-111111111111";
const T2: &str = "22222222-2222-2222-2222-222222222222";
const R1: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const DEFAULT_PROPS: &[&str] = &[pep_properties::OWNER_TENANT_ID, pep_properties::RESOURCE_ID];
#[test]
fn no_require_constraints_empty_returns_allow_all() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext::default(),
};
let scope = compile_to_access_scope(&response, false, DEFAULT_PROPS).unwrap();
assert!(scope.is_unconstrained());
}
#[test]
fn no_require_constraints_with_constraints_compiles_them() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![Predicate::Eq(EqPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
value: jid(T1),
})],
}],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, false, DEFAULT_PROPS).unwrap();
assert!(!scope.is_unconstrained());
assert_eq!(
scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
&[uuid(T1)]
);
}
#[test]
fn decision_true_require_constraints_empty_returns_error() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext::default(),
};
let result = compile_to_access_scope(&response, true, DEFAULT_PROPS);
assert!(matches!(
result,
Err(ConstraintCompileError::ConstraintsRequiredButAbsent)
));
}
#[test]
fn single_tenant_eq_constraint() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![Predicate::Eq(EqPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
value: jid(T1),
})],
}],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert_eq!(
scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
&[uuid(T1)]
);
assert!(
scope
.all_uuid_values_for(pep_properties::RESOURCE_ID)
.is_empty()
);
let filter = &scope.constraints()[0].filters()[0];
assert!(matches!(filter, ScopeFilter::Eq(_)));
}
#[test]
fn multiple_tenants_in_constraint() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T1), jid(T2)],
})],
}],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert_eq!(
scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
&[uuid(T1), uuid(T2)]
);
}
#[test]
fn resource_id_eq_constraint() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![Predicate::Eq(EqPredicate {
property: pep_properties::RESOURCE_ID.to_owned(),
value: jid(R1),
})],
}],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert!(
scope
.all_uuid_values_for(pep_properties::OWNER_TENANT_ID)
.is_empty()
);
assert_eq!(
scope.all_uuid_values_for(pep_properties::RESOURCE_ID),
&[uuid(R1)]
);
let filter = &scope.constraints()[0].filters()[0];
assert!(matches!(filter, ScopeFilter::Eq(_)));
}
#[test]
fn multiple_constraints_produce_or_scope() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![
Constraint {
predicates: vec![Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T1)],
})],
},
Constraint {
predicates: vec![Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T2)],
})],
},
],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert_eq!(scope.constraints().len(), 2);
assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uuid(T1)));
assert!(scope.contains_uuid(pep_properties::OWNER_TENANT_ID, uuid(T2)));
}
#[test]
fn unknown_predicate_fails_constraint() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![Predicate::Eq(EqPredicate {
property: "unknown_property".to_owned(),
value: jid(T1),
})],
}],
..Default::default()
},
};
let result = compile_to_access_scope(&response, true, DEFAULT_PROPS);
assert!(matches!(
result,
Err(ConstraintCompileError::AllConstraintsFailed { .. })
));
}
#[test]
fn mixed_known_and_unknown_constraints() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![
Constraint {
predicates: vec![Predicate::Eq(EqPredicate {
property: "group_id".to_owned(),
value: jid(T1),
})],
},
Constraint {
predicates: vec![Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T2)],
})],
},
],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert_eq!(
scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
&[uuid(T2)]
);
}
#[test]
fn both_tenant_and_resource_in_single_constraint() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![
Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T1)],
}),
Predicate::Eq(EqPredicate {
property: pep_properties::RESOURCE_ID.to_owned(),
value: jid(R1),
}),
],
}],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert_eq!(scope.constraints().len(), 1);
assert_eq!(
scope.all_uuid_values_for(pep_properties::OWNER_TENANT_ID),
&[uuid(T1)]
);
assert_eq!(
scope.all_uuid_values_for(pep_properties::RESOURCE_ID),
&[uuid(R1)]
);
}
#[test]
fn mixed_shape_constraints_produce_or_scope() {
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![
Constraint {
predicates: vec![
Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T1)],
}),
Predicate::Eq(EqPredicate {
property: pep_properties::RESOURCE_ID.to_owned(),
value: jid(R1),
}),
],
},
Constraint {
predicates: vec![Predicate::In(InPredicate {
property: pep_properties::OWNER_TENANT_ID.to_owned(),
values: vec![jid(T2)],
})],
},
],
..Default::default()
},
};
let scope = compile_to_access_scope(&response, true, DEFAULT_PROPS).unwrap();
assert_eq!(scope.constraints().len(), 2);
assert_eq!(scope.constraints()[0].filters().len(), 2);
assert_eq!(scope.constraints()[1].filters().len(), 1);
}
#[test]
fn supported_properties_validation() {
let limited_props: &[&str] = &[pep_properties::OWNER_TENANT_ID];
let response = EvaluationResponse {
decision: true,
context: EvaluationResponseContext {
constraints: vec![Constraint {
predicates: vec![Predicate::Eq(EqPredicate {
property: pep_properties::RESOURCE_ID.to_owned(),
value: jid(R1),
})],
}],
..Default::default()
},
};
let result = compile_to_access_scope(&response, true, limited_props);
assert!(matches!(
result,
Err(ConstraintCompileError::AllConstraintsFailed { .. })
));
}
}