use cedar_policy::{
Authorizer, Context, Decision, Entities, EntityUid, PolicySet, Request, Schema, ValidationMode,
Validator,
};
use std::str::FromStr;
use super::error::AuthzError;
pub fn validate_policies(policies: &PolicySet, schema: &Schema) -> Result<(), AuthzError> {
let validator = Validator::new(schema.clone());
let result = validator.validate(policies, ValidationMode::Strict);
if result.validation_passed() {
return Ok(());
}
let errors: Vec<String> = result.validation_errors().map(|e| e.to_string()).collect();
Err(AuthzError::PolicyValidation(errors.join("; ")))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthzDecision {
Allow,
Deny,
}
pub trait PolicyEvaluator: Send + Sync {
fn is_authorized(
&self,
entities: &Entities,
principal: EntityUid,
action: EntityUid,
resource: EntityUid,
context: Context,
) -> AuthzDecision;
fn schema(&self) -> Option<&Schema> {
None
}
}
pub struct PolicyStore {
policy_set: PolicySet,
schema: Schema,
authorizer: Authorizer,
}
impl PolicyStore {
pub fn from_text(policy_text: &str, schema_json: &str) -> Result<Self, AuthzError> {
let policy_set = PolicySet::from_str(policy_text)
.map_err(|e| AuthzError::PolicyParse(format!("{e:?}")))?;
let schema = Schema::from_json_str(schema_json)
.map_err(|e| AuthzError::SchemaParse(format!("{e:?}")))?;
validate_policies(&policy_set, &schema)?;
Ok(Self {
policy_set,
schema,
authorizer: Authorizer::new(),
})
}
pub fn schema(&self) -> &Schema {
&self.schema
}
}
impl PolicyEvaluator for PolicyStore {
fn is_authorized(
&self,
entities: &Entities,
principal: EntityUid,
action: EntityUid,
resource: EntityUid,
context: Context,
) -> AuthzDecision {
let principal_str = principal.to_string();
let action_str = action.to_string();
let resource_str = resource.to_string();
let started = std::time::Instant::now();
let cedar_req = match Request::new(principal, action, resource, context, Some(&self.schema))
{
Ok(r) => r,
Err(e) => {
tracing::error!("Cedar request validation failed: {e:?}");
tracing::info!(
target: "axess::authz::decision",
principal = %principal_str,
action = %action_str,
resource = %resource_str,
decision = "deny",
reason = "request_validation_failed",
latency_us = started.elapsed().as_micros() as u64,
);
return AuthzDecision::Deny;
}
};
let response = self
.authorizer
.is_authorized(&cedar_req, &self.policy_set, entities);
let diagnostics = response.diagnostics();
let reasons: Vec<String> = diagnostics.reason().map(|p| p.to_string()).collect();
let errors: Vec<String> = diagnostics.errors().map(|e| e.to_string()).collect();
let latency_us = started.elapsed().as_micros() as u64;
if !errors.is_empty() {
tracing::error!(
errors = ?errors,
"Cedar policy evaluation errors: denied"
);
tracing::info!(
target: "axess::authz::decision",
principal = %principal_str,
action = %action_str,
resource = %resource_str,
decision = "deny",
reason = "policy_evaluation_error",
latency_us = latency_us,
);
return AuthzDecision::Deny;
}
match response.decision() {
Decision::Allow => {
tracing::trace!(reasons = ?reasons, "Cedar: allowed");
tracing::info!(
target: "axess::authz::decision",
principal = %principal_str,
action = %action_str,
resource = %resource_str,
decision = "allow",
reasons = ?reasons,
latency_us = latency_us,
);
AuthzDecision::Allow
}
Decision::Deny => {
tracing::debug!(reasons = ?reasons, "Cedar: denied");
tracing::info!(
target: "axess::authz::decision",
principal = %principal_str,
action = %action_str,
resource = %resource_str,
decision = "deny",
reasons = ?reasons,
latency_us = latency_us,
);
AuthzDecision::Deny
}
}
}
fn schema(&self) -> Option<&Schema> {
Some(&self.schema)
}
}
pub(super) fn make_uid(
namespace: &str,
type_name: &str,
id: &str,
) -> Result<EntityUid, AuthzError> {
super::provider::make_entity_uid(namespace, type_name, id)
}
#[cfg(test)]
mod authz_store_tests {
use super::*;
#[test]
fn make_uid_keeps_the_id_as_data() {
let escaped = make_uid("TestApp", "User", r"\u{61}lice").expect("escaped id is an id");
let plain = make_uid("TestApp", "User", "alice").expect("plain id");
assert_ne!(
escaped, plain,
"an escape sequence must not decode into another principal"
);
assert_eq!(escaped.id().unescaped(), r"\u{61}lice");
assert!(
make_uid("TestApp", "User", "a\"b\\c").is_ok(),
"a quote or backslash in an id is data"
);
}
const SCHEMA_JSON: &str = r#"{
"TestApp": {
"entityTypes": {
"User": { "memberOfTypes": [] },
"Resource": { "memberOfTypes": [] }
},
"actions": {
"View": {
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Resource"]
}
}
}
}
}"#;
const POLICY_TEXT: &str = r#"permit(
principal == TestApp::User::"alice",
action == TestApp::Action::"View",
resource == TestApp::Resource::"doc1"
);"#;
fn build_store() -> PolicyStore {
PolicyStore::from_text(POLICY_TEXT, SCHEMA_JSON).unwrap()
}
const COUNTING_SCHEMA_JSON: &str = r#"{
"TestApp": {
"entityTypes": {
"User": {
"memberOfTypes": [],
"shape": {
"type": "Record",
"attributes": { "n": { "type": "Long" } }
}
},
"Resource": { "memberOfTypes": [] }
},
"actions": {
"View": {
"appliesTo": {
"principalTypes": ["User"],
"resourceTypes": ["Resource"]
}
}
}
}
}"#;
fn alice_with(n: i64, store: &PolicyStore) -> Entities {
let json = format!(
r#"[{{"uid": {{"type": "TestApp::User", "id": "alice"}}, "attrs": {{"n": {n}}}, "parents": []}}]"#
);
Entities::from_json_str(&json, Some(store.schema())).unwrap()
}
fn view_doc1(store: &PolicyStore, entities: &Entities) -> AuthzDecision {
store.is_authorized(
entities,
r#"TestApp::User::"alice""#.parse().unwrap(),
r#"TestApp::Action::"View""#.parse().unwrap(),
r#"TestApp::Resource::"doc1""#.parse().unwrap(),
Context::empty(),
)
}
#[test]
fn an_erroring_forbid_fails_closed() {
let policies = r#"
permit(principal, action == TestApp::Action::"View", resource);
forbid(principal, action, resource)
when { principal.n + 9223372036854775807 > 0 };
"#;
let store = PolicyStore::from_text(policies, COUNTING_SCHEMA_JSON).unwrap();
assert_eq!(
view_doc1(&store, &alice_with(0, &store)),
AuthzDecision::Deny
);
assert_eq!(
view_doc1(&store, &alice_with(1, &store)),
AuthzDecision::Deny
);
}
#[test]
fn an_erroring_permit_denies() {
let policies = r#"
permit(principal, action == TestApp::Action::"View", resource)
when { principal.n + 9223372036854775807 > 0 };
"#;
let store = PolicyStore::from_text(policies, COUNTING_SCHEMA_JSON).unwrap();
assert_eq!(
view_doc1(&store, &alice_with(1, &store)),
AuthzDecision::Deny
);
assert_eq!(
view_doc1(&store, &alice_with(-1, &store)),
AuthzDecision::Allow
);
}
#[test]
fn a_working_permit_beside_an_erroring_one_denies() {
let policies = r#"
permit(principal, action == TestApp::Action::"View", resource);
permit(principal, action == TestApp::Action::"View", resource)
when { principal.n + 9223372036854775807 > 0 };
"#;
let store = PolicyStore::from_text(policies, COUNTING_SCHEMA_JSON).unwrap();
assert_eq!(
view_doc1(&store, &alice_with(1, &store)),
AuthzDecision::Deny
);
assert_eq!(
view_doc1(&store, &alice_with(-1, &store)),
AuthzDecision::Allow
);
}
#[test]
fn policy_evaluator_schema_returns_some_when_loaded() {
let store = build_store();
assert!(
<PolicyStore as PolicyEvaluator>::schema(&store).is_some(),
"PolicyStore::schema must return Some, not None"
);
}
#[test]
fn policy_store_schema_accessor_is_borrow_of_inner() {
let store = build_store();
let s1 = store.schema() as *const _;
let s2 = store.schema() as *const _;
assert_eq!(
s1, s2,
"schema() must borrow the stored Schema, not produce a new one"
);
}
}