use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_yaml_ng as serde_yaml;
use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, Once};
use crate::acl_handlers::{register_builtin_handlers, CONDITION_HANDLERS};
use crate::context::Context;
use crate::errors::{ErrorCode, ModuleError};
use crate::utils::match_pattern;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ACLRule {
#[serde(default)]
pub callers: Vec<String>,
#[serde(default)]
pub targets: Vec<String>,
pub effect: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conditions: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: String,
pub caller_id: String,
pub target_id: String,
pub decision: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_rule_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_type: Option<String>,
#[serde(default)]
pub roles: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call_depth: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
}
type AuditLoggerFn = dyn Fn(&AuditEntry) + Send + Sync;
pub struct ACL {
rules: Vec<ACLRule>,
default_effect: String,
yaml_path: Option<String>,
audit_logger: Option<Arc<AuditLoggerFn>>,
}
impl std::fmt::Debug for ACL {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ACL")
.field("rules", &self.rules)
.field("default_effect", &self.default_effect)
.field("yaml_path", &self.yaml_path)
.field("audit_logger", &self.audit_logger.as_ref().map(|_| "..."))
.finish()
}
}
impl Clone for ACL {
fn clone(&self) -> Self {
Self {
rules: self.rules.clone(),
default_effect: self.default_effect.clone(),
yaml_path: self.yaml_path.clone(),
audit_logger: self.audit_logger.clone(),
}
}
}
impl ACL {
pub fn new(
rules: Vec<ACLRule>,
default_effect: impl Into<String>,
audit_logger: Option<Arc<AuditLoggerFn>>,
) -> Self {
Self {
rules,
default_effect: default_effect.into(),
yaml_path: None,
audit_logger,
}
}
pub fn set_audit_logger(&mut self, logger: impl Fn(&AuditEntry) + Send + Sync + 'static) {
self.audit_logger = Some(Arc::new(logger));
}
pub fn evaluate_conditions(
conditions: &HashMap<String, serde_json::Value>,
ctx: &Context<serde_json::Value>,
) -> bool {
let mut to_evaluate = Vec::with_capacity(conditions.len());
{
let handlers = CONDITION_HANDLERS.read();
for (key, value) in conditions {
let handler = if let Some(h) = handlers.get(key.as_str()) {
h.clone()
} else {
tracing::warn!("Unknown ACL condition '{}' — treated as unsatisfied", key);
return false;
};
to_evaluate.push((key, handler, value));
}
}
for (key, handler, value) in to_evaluate {
let fut = handler.evaluate(value, ctx);
let fut = std::pin::pin!(fut);
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
let result = match fut.poll(&mut cx) {
std::task::Poll::Ready(val) => val,
std::task::Poll::Pending => {
tracing::warn!(
"Async condition '{}' not immediately ready in sync context — treated as unsatisfied",
key,
);
return false;
}
};
if !result {
return false;
}
}
true
}
pub async fn evaluate_conditions_async(
conditions: &HashMap<String, serde_json::Value>,
ctx: &Context<serde_json::Value>,
) -> bool {
let mut to_evaluate = Vec::with_capacity(conditions.len());
{
let handlers = CONDITION_HANDLERS.read();
for (key, value) in conditions {
let handler = if let Some(h) = handlers.get(key.as_str()) {
h.clone()
} else {
tracing::warn!("Unknown ACL condition '{}' — treated as unsatisfied", key);
return false;
};
to_evaluate.push((handler, value));
}
}
for (handler, value) in to_evaluate {
if !handler.evaluate(value, ctx).await {
return false;
}
}
true
}
pub fn add_rule(&mut self, rule: ACLRule) -> Result<(), ModuleError> {
self.rules.insert(0, rule);
Ok(())
}
pub fn remove_rule(&mut self, callers: &[String], targets: &[String]) -> bool {
if let Some(pos) = self
.rules
.iter()
.position(|r| r.callers == callers && r.targets == targets)
{
self.rules.remove(pos);
true
} else {
false
}
}
pub fn check(
&self,
caller_id: Option<&str>,
target_id: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> Result<bool, ModuleError> {
let caller = caller_id.unwrap_or("@external");
if self.rules.is_empty() {
return Ok(self.finalize_no_rules(caller, target_id, ctx));
}
for (idx, rule) in self.rules.iter().enumerate() {
if self.matches_rule(rule, caller, target_id, ctx) {
return Ok(self.finalize_rule_match(idx, rule, caller, target_id, ctx));
}
}
Ok(self.finalize_default_effect(caller, target_id, ctx))
}
pub fn load(path: &str) -> Result<Self, ModuleError> {
let content = std::fs::read_to_string(path).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigNotFound,
format!("Failed to read ACL file '{path}': {e}"),
)
})?;
let raw: serde_json::Value = serde_yaml::from_str(&content).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigInvalid,
format!("Failed to parse ACL file '{path}': {e}"),
)
})?;
let rules_val = raw.get("rules").ok_or_else(|| {
ModuleError::new(
ErrorCode::ConfigInvalid,
format!("ACL file '{path}' missing 'rules' key"),
)
})?;
let rules: Vec<ACLRule> = serde_json::from_value(rules_val.clone()).map_err(|e| {
ModuleError::new(
ErrorCode::ConfigInvalid,
format!("Invalid ACL rules in '{path}': {e}"),
)
})?;
let default_effect = raw
.get("default_effect")
.and_then(|v| v.as_str())
.unwrap_or("deny")
.to_string();
let mut acl = Self::new(rules, default_effect, None);
acl.yaml_path = Some(path.to_string());
Ok(acl)
}
pub fn register_condition(
key: impl Into<String>,
handler: std::sync::Arc<dyn crate::acl_handlers::ACLConditionHandler>,
) {
crate::acl_handlers::register_condition(key, handler);
}
pub fn reload(&mut self) -> Result<(), ModuleError> {
let path = self.yaml_path.clone().ok_or_else(|| {
ModuleError::new(
ErrorCode::ReloadFailed,
"Cannot reload: no yaml_path stored".to_string(),
)
})?;
let reloaded = Self::load(&path)?;
self.rules = reloaded.rules;
self.default_effect = reloaded.default_effect;
Ok(())
}
pub fn rules(&self) -> &[ACLRule] {
&self.rules
}
fn matches_rule(
&self,
rule: &ACLRule,
caller: &str,
target: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
if !Self::match_patterns(&rule.callers, caller, ctx) {
return false;
}
if !Self::match_patterns(&rule.targets, target, ctx) {
return false;
}
if let Some(ref conditions) = rule.conditions {
if !self.check_conditions(conditions, ctx) {
return false;
}
}
true
}
fn match_patterns(
patterns: &[String],
value: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
if patterns.is_empty() {
return false;
}
let first = patterns[0].as_str();
if first == "$or" {
return patterns[1..]
.iter()
.any(|p| Self::match_acl_pattern_with_ctx(p, value, ctx));
}
if first == "$not" {
if patterns.len() < 2 {
return false;
}
return !Self::match_acl_pattern_with_ctx(&patterns[1], value, ctx);
}
patterns
.iter()
.any(|p| Self::match_acl_pattern_with_ctx(p, value, ctx))
}
fn match_acl_pattern(pattern: &str, value: &str) -> bool {
if pattern == "@external" {
return value == "@external";
}
if pattern == "@system" {
return false; }
match_pattern(pattern, value)
}
fn match_acl_pattern_with_ctx(
pattern: &str,
value: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
if pattern == "@system" {
return ctx
.and_then(|c| c.identity.as_ref())
.is_some_and(|id| id.identity_type() == "system");
}
Self::match_acl_pattern(pattern, value)
}
#[allow(clippy::unused_self)] fn check_conditions(
&self,
conditions: &serde_json::Value,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
let Some(ctx) = ctx else {
return false; };
let Some(obj) = conditions.as_object() else {
return false;
};
let map: HashMap<String, serde_json::Value> =
obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
Self::evaluate_conditions(&map, ctx)
}
#[allow(clippy::unused_self)] async fn check_conditions_async(
&self,
conditions: &serde_json::Value,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
let Some(ctx) = ctx else {
return false;
};
let Some(obj) = conditions.as_object() else {
return false;
};
let map: HashMap<String, serde_json::Value> =
obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
Self::evaluate_conditions_async(&map, ctx).await
}
fn finalize_no_rules(
&self,
caller: &str,
target_id: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
let entry = self.build_audit_entry(
caller,
target_id,
&self.default_effect,
"no_rules",
None,
None,
ctx,
);
self.emit_audit(&entry);
self.default_effect == "allow"
}
fn finalize_rule_match(
&self,
idx: usize,
rule: &ACLRule,
caller: &str,
target_id: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
let entry = self.build_audit_entry(
caller,
target_id,
&rule.effect,
"rule_match",
rule.description.as_deref(),
Some(idx),
ctx,
);
self.emit_audit(&entry);
rule.effect == "allow"
}
fn finalize_default_effect(
&self,
caller: &str,
target_id: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
let entry = self.build_audit_entry(
caller,
target_id,
&self.default_effect,
"default_effect",
None,
None,
ctx,
);
self.emit_audit(&entry);
self.default_effect == "allow"
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::unused_self)] fn build_audit_entry(
&self,
caller_id: &str,
target_id: &str,
decision: &str,
reason: &str,
matched_rule_desc: Option<&str>,
matched_rule_index: Option<usize>,
ctx: Option<&Context<serde_json::Value>>,
) -> AuditEntry {
AuditEntry {
timestamp: Utc::now().to_rfc3339(),
caller_id: caller_id.to_string(),
target_id: target_id.to_string(),
decision: decision.to_string(),
reason: reason.to_string(),
matched_rule: matched_rule_desc.map(std::string::ToString::to_string),
matched_rule_index,
identity_type: ctx
.and_then(|c| c.identity.as_ref().map(|id| id.identity_type().to_string())),
roles: ctx
.and_then(|c| c.identity.as_ref().map(|id| id.roles().to_vec()))
.unwrap_or_default(),
call_depth: ctx.map(|c| c.call_chain.len()),
trace_id: ctx.map(|c| c.trace_id.clone()),
}
}
pub async fn async_check(
&self,
caller_id: Option<&str>,
target_id: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> Result<bool, ModuleError> {
let caller = caller_id.unwrap_or("@external");
if self.rules.is_empty() {
return Ok(self.finalize_no_rules(caller, target_id, ctx));
}
for (idx, rule) in self.rules.iter().enumerate() {
if self.matches_rule_async(rule, caller, target_id, ctx).await {
return Ok(self.finalize_rule_match(idx, rule, caller, target_id, ctx));
}
}
Ok(self.finalize_default_effect(caller, target_id, ctx))
}
async fn matches_rule_async(
&self,
rule: &ACLRule,
caller: &str,
target: &str,
ctx: Option<&Context<serde_json::Value>>,
) -> bool {
if !Self::match_patterns(&rule.callers, caller, ctx) {
return false;
}
if !Self::match_patterns(&rule.targets, target, ctx) {
return false;
}
if let Some(ref conditions) = rule.conditions {
if !self.check_conditions_async(conditions, ctx).await {
return false;
}
}
true
}
fn emit_audit(&self, entry: &AuditEntry) {
if let Some(ref logger) = self.audit_logger {
logger(entry);
}
}
pub fn init_builtin_handlers() {
static INIT: Once = Once::new();
INIT.call_once(|| {
register_builtin_handlers(Self::evaluate_conditions);
});
}
}
impl Default for ACL {
fn default() -> Self {
Self::new(vec![], "deny", None)
}
}