use crate::coordinator::session::SessionRequest;
use chrono::{DateTime, Timelike, Utc};
#[derive(Debug, Clone)]
pub struct PolicyContext {
pub now: DateTime<Utc>,
pub quorum_active_sessions: usize,
}
impl Default for PolicyContext {
fn default() -> Self {
Self {
now: Utc::now(),
quorum_active_sessions: 0,
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum PolicyDenial {
#[error("request outside allowed hours: {hour:02}:00 (allowed {start:02}:00–{end:02}:00)")]
OutsideTimeWindow {
hour: u32,
start: u32,
end: u32,
},
#[error("too many concurrent sessions: {active} (max {max})")]
TooManySessions {
active: usize,
max: usize,
},
#[error("message too large: {size} bytes (max {max})")]
MessageTooLarge {
size: usize,
max: usize,
},
#[error("threshold {threshold} exceeds maximum {max}")]
ThresholdTooHigh {
threshold: u32,
max: u32,
},
}
pub trait Rule: Send + Sync {
fn evaluate(&self, request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial>;
}
pub struct PolicyEngine {
rules: Vec<Box<dyn Rule>>,
}
impl PolicyEngine {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule(&mut self, rule: Box<dyn Rule>) {
self.rules.push(rule);
}
pub fn evaluate(
&self,
request: &SessionRequest,
ctx: &PolicyContext,
) -> Result<(), PolicyDenial> {
for rule in &self.rules {
rule.evaluate(request, ctx)?;
}
Ok(())
}
}
impl Default for PolicyEngine {
fn default() -> Self {
Self::new()
}
}
pub struct TimeWindowRule {
pub start_hour: u32,
pub end_hour: u32,
}
impl Rule for TimeWindowRule {
fn evaluate(&self, _request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial> {
let hour = ctx.now.hour();
if hour >= self.start_hour && hour < self.end_hour {
Ok(())
} else {
Err(PolicyDenial::OutsideTimeWindow {
hour,
start: self.start_hour,
end: self.end_hour,
})
}
}
}
pub struct MaxConcurrentSessionsRule {
pub max: usize,
}
impl Rule for MaxConcurrentSessionsRule {
fn evaluate(&self, _request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial> {
if ctx.quorum_active_sessions >= self.max {
Err(PolicyDenial::TooManySessions {
active: ctx.quorum_active_sessions,
max: self.max,
})
} else {
Ok(())
}
}
}
pub struct MessageSizeRule {
pub max_bytes: usize,
}
impl Rule for MessageSizeRule {
fn evaluate(&self, request: &SessionRequest, _ctx: &PolicyContext) -> Result<(), PolicyDenial> {
if request.message.len() > self.max_bytes {
Err(PolicyDenial::MessageTooLarge {
size: request.message.len(),
max: self.max_bytes,
})
} else {
Ok(())
}
}
}
pub struct MaxThresholdRule {
pub max: u32,
}
impl Rule for MaxThresholdRule {
fn evaluate(&self, request: &SessionRequest, _ctx: &PolicyContext) -> Result<(), PolicyDenial> {
if request.threshold > self.max {
Err(PolicyDenial::ThresholdTooHigh {
threshold: request.threshold,
max: self.max,
})
} else {
Ok(())
}
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use crate::coordinator::session::SessionRequest;
fn make_request(threshold: u32, message: Vec<u8>) -> SessionRequest {
SessionRequest {
quorum_id: "test-quorum".into(),
scheme: "CMP20".into(),
message,
threshold,
num_parties: 5,
unlock_window_minutes: 60,
requested_by: "tester".into(),
}
}
#[test]
fn empty_engine_allows_all() {
let engine = PolicyEngine::new();
let req = make_request(2, vec![0; 32]);
let ctx = PolicyContext::default();
assert!(engine.evaluate(&req, &ctx).is_ok());
}
#[test]
fn time_window_allows_during_business_hours() {
let rule = TimeWindowRule {
start_hour: 9,
end_hour: 17,
};
let req = make_request(2, vec![0; 32]);
let mut ctx = PolicyContext::default();
ctx.now = Utc::now().with_hour(12).unwrap();
assert!(rule.evaluate(&req, &ctx).is_ok());
}
#[test]
fn time_window_denies_outside_hours() {
let rule = TimeWindowRule {
start_hour: 9,
end_hour: 17,
};
let req = make_request(2, vec![0; 32]);
let mut ctx = PolicyContext::default();
ctx.now = Utc::now().with_hour(23).unwrap();
assert!(rule.evaluate(&req, &ctx).is_err());
}
#[test]
fn max_sessions_denies_when_exceeded() {
let rule = MaxConcurrentSessionsRule { max: 3 };
let req = make_request(2, vec![0; 32]);
let mut ctx = PolicyContext::default();
ctx.quorum_active_sessions = 3;
assert!(rule.evaluate(&req, &ctx).is_err());
}
#[test]
fn max_sessions_allows_under_limit() {
let rule = MaxConcurrentSessionsRule { max: 3 };
let req = make_request(2, vec![0; 32]);
let mut ctx = PolicyContext::default();
ctx.quorum_active_sessions = 2;
assert!(rule.evaluate(&req, &ctx).is_ok());
}
#[test]
fn message_size_denies_too_large() {
let rule = MessageSizeRule { max_bytes: 64 };
let req = make_request(2, vec![0; 128]);
let ctx = PolicyContext::default();
assert!(rule.evaluate(&req, &ctx).is_err());
}
#[test]
fn threshold_denies_too_high() {
let rule = MaxThresholdRule { max: 5 };
let req = make_request(7, vec![0; 32]);
let ctx = PolicyContext::default();
assert!(rule.evaluate(&req, &ctx).is_err());
}
#[test]
fn multiple_rules_first_denial_wins() {
let mut engine = PolicyEngine::new();
engine.add_rule(Box::new(MaxThresholdRule { max: 5 }));
engine.add_rule(Box::new(MessageSizeRule { max_bytes: 64 }));
let req = make_request(7, vec![0; 128]);
let ctx = PolicyContext::default();
let result = engine.evaluate(&req, &ctx);
match result {
Err(PolicyDenial::ThresholdTooHigh { threshold, .. }) => {
assert_eq!(threshold, 7);
}
_ => panic!("expected ThresholdTooHigh"),
}
}
#[test]
fn multiple_rules_all_pass() {
let mut engine = PolicyEngine::new();
engine.add_rule(Box::new(MaxThresholdRule { max: 10 }));
engine.add_rule(Box::new(MessageSizeRule { max_bytes: 1024 }));
engine.add_rule(Box::new(MaxConcurrentSessionsRule { max: 5 }));
let req = make_request(3, vec![0; 32]);
let ctx = PolicyContext {
quorum_active_sessions: 2,
..Default::default()
};
assert!(engine.evaluate(&req, &ctx).is_ok());
}
}