use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use std::sync::LazyLock;
use cedar_policy::{
Authorizer, Context, Entities, Entity, EntityUid, PolicySet, Request, Schema, ValidationMode,
Validator,
};
use metrics::counter;
use parking_lot::RwLock;
use tracing::{debug, info, warn};
use crate::authz::error::{AuthzError, Result};
use crate::authz::model::{
ChronixAction, ChronixNamespace, ChronixPrincipal, ChronixSystem, Decision,
};
pub const SCHEMA_SRC: &str = include_str!("chronix.cedarschema");
static SCHEMA: LazyLock<Schema> = LazyLock::new(|| {
let (schema, warnings) = Schema::from_cedarschema_str(SCHEMA_SRC)
.expect("the compiled-in Chronix schema must parse");
for w in warnings {
warn!(warning = %w, "Chronix Cedar schema warning");
}
schema
});
pub struct AuthzEngine {
authorizer: Authorizer,
policies: RwLock<Arc<PolicySet>>,
}
impl AuthzEngine {
#[must_use]
pub fn new() -> Self {
Self {
authorizer: Authorizer::new(),
policies: RwLock::new(Arc::new(PolicySet::new())),
}
}
fn validate(ps: &PolicySet) -> Result<()> {
let validator = Validator::new(SCHEMA.clone());
let result = validator.validate(ps, ValidationMode::Strict);
let errors: Vec<String> = result.validation_errors().map(|e| format!("{e}")).collect();
if !errors.is_empty() {
return Err(AuthzError::PolicyValidation(errors.join("; ")));
}
let mut never_matches: Vec<String> = Vec::new();
for w in result.validation_warnings() {
let text = format!("{w}");
if text.contains("impossible") || text.contains("will never") {
never_matches.push(text);
} else {
warn!(warning = %text, "Cedar policy validation warning");
}
}
if !never_matches.is_empty() {
return Err(AuthzError::PolicyValidation(never_matches.join("; ")));
}
Ok(())
}
pub fn check_policies(policy_src: &str) -> Result<usize> {
let ps = policy_src
.parse::<PolicySet>()
.map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
Self::validate(&ps)?;
Ok(ps.policies().count())
}
pub fn load_policies(&self, policy_src: &str) -> Result<usize> {
let ps = policy_src
.parse::<PolicySet>()
.map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
Self::validate(&ps)?;
let count = ps.policies().count();
let policy_ids: Vec<String> = ps.policies().map(|p| p.id().to_string()).collect();
*self.policies.write() = Arc::new(ps);
info!(count, policies = ?policy_ids, "Cedar policies loaded");
counter!("chronix_authz_policy_load", "operation" => "load_all").increment(1);
Ok(count)
}
pub fn add_policy(&self, id: &str, policy_src: &str) -> Result<()> {
let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), policy_src)
.map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
let mut candidate = {
let ps = self.policies.read();
let mut candidate = PolicySet::new();
for p in ps.policies() {
candidate
.add(p.clone())
.map_err(|e| AuthzError::Internal(format!("Duplicate policy: {e}")))?;
}
candidate
};
candidate
.add(policy)
.map_err(|e| AuthzError::Internal(format!("Failed to add policy: {e}")))?;
Self::validate(&candidate)?;
let total = candidate.policies().count();
*self.policies.write() = Arc::new(candidate);
info!(policy_id = id, total_policies = total, "Cedar policy added");
counter!("chronix_authz_policy_load", "operation" => "add").increment(1);
Ok(())
}
pub fn remove_policy(&self, id: &str) -> bool {
let mut guard = self.policies.write();
let mut ps = PolicySet::new();
for p in guard.policies() {
if let Err(e) = ps.add(p.clone()) {
tracing::error!(error = %e, "failed to re-add policy while removing another");
}
}
match ps.remove_static(cedar_policy::PolicyId::new(id)) {
Ok(_) => {
let remaining = ps.policies().count();
*guard = Arc::new(ps);
info!(
policy_id = id,
remaining_policies = remaining,
"Cedar policy removed"
);
counter!("chronix_authz_policy_load", "operation" => "remove").increment(1);
true
}
Err(_) => {
warn!(id, "Policy not found for removal");
false
}
}
}
pub fn load_policies_from_dir(&self, dir: &Path) -> Result<usize> {
let mut combined = String::new();
let mut file_count = 0;
let mut paths: Vec<_> = std::fs::read_dir(dir)?
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "cedar"))
.collect();
paths.sort();
for path in paths {
combined.push_str(&std::fs::read_to_string(&path)?);
combined.push('\n');
file_count += 1;
debug!(path = %path.display(), "Loaded policy file");
}
if file_count == 0 {
info!(dir = %dir.display(), "No .cedar files found");
return Ok(0);
}
self.load_policies(&combined)
}
#[must_use]
pub fn policy_count(&self) -> usize {
self.policies.read().policies().count()
}
#[must_use]
pub fn policy_ids(&self) -> Vec<String> {
self.policies
.read()
.policies()
.map(|p| p.id().to_string())
.collect()
}
#[must_use]
pub fn authorize_namespace(
&self,
principal: &ChronixPrincipal,
action: ChronixAction,
namespace: &ChronixNamespace,
) -> Decision {
debug_assert!(
!action.is_administrative(),
"{action} applies to the system, not to a namespace"
);
match namespace.to_entity() {
Ok(resource) => self.evaluate(
principal,
action,
resource,
namespace.to_entity_uid(),
"namespace",
),
Err(e) => Self::internal_deny(&e),
}
}
#[must_use]
pub fn authorize_system(
&self,
principal: &ChronixPrincipal,
action: ChronixAction,
) -> Decision {
debug_assert!(
action.is_administrative(),
"{action} applies to a namespace, not to the system"
);
match ChronixSystem::to_entity() {
Ok(resource) => self.evaluate(
principal,
action,
resource,
ChronixSystem::to_entity_uid(),
"system",
),
Err(e) => Self::internal_deny(&e),
}
}
fn internal_deny(e: &AuthzError) -> Decision {
tracing::error!(error = %e, "Cedar entity construction failed");
Decision::Deny {
reasons: vec!["internal authorization error".into()],
}
}
fn evaluate(
&self,
principal: &ChronixPrincipal,
action: ChronixAction,
resource: Entity,
resource_uid: EntityUid,
resource_label: &'static str,
) -> Decision {
let principal_entity = match principal.to_entity() {
Ok(e) => e,
Err(e) => return Self::internal_deny(&e),
};
let role_entities = match principal.role_entities() {
Ok(v) => v,
Err(e) => return Self::internal_deny(&e),
};
let mut all = vec![principal_entity, resource];
all.extend(role_entities);
let entities = match Entities::from_entities(all, Some(&SCHEMA)) {
Ok(e) => e,
Err(e) => {
return Self::internal_deny(&AuthzError::Internal(format!("entity set: {e}")));
}
};
let request = match Request::new(
principal.to_entity_uid(),
action.to_entity_uid(),
resource_uid,
Context::empty(),
Some(&SCHEMA),
) {
Ok(r) => r,
Err(e) => return Self::internal_deny(&AuthzError::Internal(format!("request: {e}"))),
};
let policies = Arc::clone(&self.policies.read());
let response = self
.authorizer
.is_authorized(&request, &policies, &entities);
let decision_label = match response.decision() {
cedar_policy::Decision::Allow => "allow",
cedar_policy::Decision::Deny => "deny",
};
counter!(
"chronix_authz_decisions_total",
"action" => action.to_string(),
"resource_type" => resource_label,
"decision" => decision_label,
)
.increment(1);
match response.decision() {
cedar_policy::Decision::Allow => Decision::Allow,
cedar_policy::Decision::Deny => {
let eval_errors: Vec<String> = response
.diagnostics()
.errors()
.map(std::string::ToString::to_string)
.collect();
let policy_reasons: Vec<String> = response
.diagnostics()
.reason()
.map(std::string::ToString::to_string)
.collect();
if !policy_reasons.is_empty() || !eval_errors.is_empty() {
debug!(
?policy_reasons,
?eval_errors,
%action,
principal = %principal.id,
"authorization denied with diagnostics"
);
}
Decision::Deny {
reasons: vec!["access denied".into()],
}
}
}
}
}
impl Default for AuthzEngine {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for AuthzEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthzEngine")
.field("policy_count", &self.policy_count())
.finish()
}
}
pub fn schema_action_names() -> Result<HashSet<String>> {
let entities = SCHEMA
.action_entities()
.map_err(|e| AuthzError::Internal(format!("schema actions: {e}")))?;
Ok(entities
.iter()
.map(|e| e.uid().id().escaped().trim_matches('"').to_string())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn alice(role: &str) -> ChronixPrincipal {
ChronixPrincipal::new("alice").with_role(role)
}
fn prod() -> ChronixNamespace {
ChronixNamespace::new("prod")
}
#[test]
fn the_builtin_schema_parses() {
let _ = &*SCHEMA;
assert!(!schema_action_names().unwrap().is_empty());
}
#[test]
fn the_schema_describes_exactly_the_actions_we_issue() {
let mut declared = schema_action_names().unwrap();
assert!(declared.remove("Admin"), "the Admin group is gone");
let issued: HashSet<String> = ChronixAction::ALL
.iter()
.map(|a| a.name().to_string())
.collect();
assert_eq!(declared, issued);
}
#[test]
fn a_policy_naming_an_unknown_action_is_refused() {
for bad in [
r#"permit(principal, action == Chronix::Action::"Query", resource);"#,
r#"permit(principal, action == Action::"Write", resource);"#,
r#"permit(principal in Group::"platform-team", action, resource);"#,
r#"permit(principal, action, resource in Namespace::"team-platform");"#,
r#"permit(principal, action == Chronix::Action::"CreateMeasurement", resource);"#,
] {
assert!(
AuthzEngine::check_policies(bad).is_err(),
"should have been refused: {bad}"
);
}
}
#[test]
fn an_admin_action_aimed_at_a_namespace_is_refused() {
let bad = r#"
permit(
principal,
action == Chronix::Action::"ManageKeys",
resource == Chronix::Namespace::"prod"
);
"#;
assert!(AuthzEngine::check_policies(bad).is_err());
}
#[test]
fn default_deny() {
let engine = AuthzEngine::new();
assert!(
engine
.authorize_namespace(&alice("reader"), ChronixAction::Read, &prod())
.is_denied()
);
assert!(
engine
.authorize_system(&alice("reader"), ChronixAction::ManageKeys)
.is_denied()
);
}
#[test]
fn a_role_grants_within_one_namespace_only() {
let engine = AuthzEngine::new();
engine
.load_policies(
r#"
permit(
principal in Chronix::Role::"prod_reader",
action == Chronix::Action::"Read",
resource == Chronix::Namespace::"prod"
);
"#,
)
.unwrap();
let p = alice("prod_reader");
assert!(
engine
.authorize_namespace(&p, ChronixAction::Read, &prod())
.is_allowed()
);
assert!(
engine
.authorize_namespace(&p, ChronixAction::Read, &ChronixNamespace::new("staging"))
.is_denied()
);
assert!(
engine
.authorize_namespace(&p, ChronixAction::Write, &prod())
.is_denied()
);
}
#[test]
fn a_principal_without_the_role_is_denied() {
let engine = AuthzEngine::new();
engine
.load_policies(
r#"permit(principal in Chronix::Role::"writer",
action == Chronix::Action::"Write", resource);"#,
)
.unwrap();
assert!(
engine
.authorize_namespace(&alice("reader"), ChronixAction::Write, &prod())
.is_denied()
);
assert!(
engine
.authorize_namespace(&alice("writer"), ChronixAction::Write, &prod())
.is_allowed()
);
}
#[test]
fn the_admin_group_grants_every_capability_and_one_action_grants_one() {
let engine = AuthzEngine::new();
engine
.load_policies(
r#"
permit(
principal in Chronix::Role::"root",
action in Chronix::Action::"Admin",
resource
);
permit(
principal in Chronix::Role::"backup_operator",
action == Chronix::Action::"ManageBackups",
resource
);
"#,
)
.unwrap();
let root = alice("root");
for action in ChronixAction::ALL
.iter()
.copied()
.filter(|a| a.is_administrative())
{
assert!(
engine.authorize_system(&root, action).is_allowed(),
"the Admin group should cover {action}"
);
}
let backup = alice("backup_operator");
assert!(
engine
.authorize_system(&backup, ChronixAction::ManageBackups)
.is_allowed()
);
assert!(
engine
.authorize_system(&backup, ChronixAction::ManageKeys)
.is_denied()
);
assert!(
engine
.authorize_system(&backup, ChronixAction::ManageNamespaces)
.is_denied()
);
}
#[test]
fn a_forbid_overrides_a_permit() {
let engine = AuthzEngine::new();
engine
.load_policies(
r#"
permit(principal, action == Chronix::Action::"Read", resource);
forbid(principal, action, resource == Chronix::Namespace::"secret");
"#,
)
.unwrap();
assert!(
engine
.authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
.is_allowed()
);
assert!(
engine
.authorize_namespace(
&alice("any"),
ChronixAction::Read,
&ChronixNamespace::new("secret")
)
.is_denied()
);
}
#[test]
fn a_principal_named_directly_needs_no_role() {
let engine = AuthzEngine::new();
engine
.load_policies(
r#"permit(principal == Chronix::User::"ingest-key",
action == Chronix::Action::"Write", resource);"#,
)
.unwrap();
assert!(
engine
.authorize_namespace(
&ChronixPrincipal::new("ingest-key"),
ChronixAction::Write,
&prod()
)
.is_allowed()
);
assert!(
engine
.authorize_namespace(
&ChronixPrincipal::new("other-key"),
ChronixAction::Write,
&prod()
)
.is_denied()
);
}
#[test]
fn a_rejected_policy_leaves_the_active_set_alone() {
let engine = AuthzEngine::new();
engine
.load_policies(r#"permit(principal, action == Chronix::Action::"Read", resource);"#)
.unwrap();
assert_eq!(engine.policy_count(), 1);
assert!(
engine
.add_policy(
"bad",
r#"permit(principal, action == Chronix::Action::"Query", resource);"#
)
.is_err()
);
assert_eq!(engine.policy_count(), 1);
assert!(
engine
.authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
.is_allowed()
);
}
#[test]
fn add_and_remove_a_policy() {
let engine = AuthzEngine::new();
engine
.add_policy(
"readers",
r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
)
.unwrap();
assert_eq!(engine.policy_ids(), vec!["readers".to_string()]);
assert!(engine.remove_policy("readers"));
assert!(!engine.remove_policy("readers"));
assert_eq!(engine.policy_count(), 0);
}
#[test]
fn policies_load_from_a_directory_in_a_stable_order() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("b.cedar"),
r#"permit(principal, action == Chronix::Action::"Write", resource);"#,
)
.unwrap();
std::fs::write(
dir.path().join("a.cedar"),
r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
)
.unwrap();
std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap();
let engine = AuthzEngine::new();
assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 2);
assert!(
engine
.authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
.is_allowed()
);
}
#[test]
fn an_empty_directory_loads_nothing_and_denies_everything() {
let dir = tempfile::tempdir().unwrap();
let engine = AuthzEngine::new();
assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 0);
assert!(
engine
.authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
.is_denied()
);
}
}