confium_coordinator/coordinator/
policy.rs1use crate::coordinator::session::SessionRequest;
14use chrono::{DateTime, Timelike, Utc};
15
16#[derive(Debug, Clone)]
18pub struct PolicyContext {
19 pub now: DateTime<Utc>,
21 pub quorum_active_sessions: usize,
23}
24
25impl Default for PolicyContext {
26 fn default() -> Self {
27 Self {
28 now: Utc::now(),
29 quorum_active_sessions: 0,
30 }
31 }
32}
33
34#[derive(Debug, Clone, thiserror::Error)]
36pub enum PolicyDenial {
37 #[error("request outside allowed hours: {hour:02}:00 (allowed {start:02}:00–{end:02}:00)")]
39 OutsideTimeWindow {
40 hour: u32,
42 start: u32,
44 end: u32,
46 },
47 #[error("too many concurrent sessions: {active} (max {max})")]
49 TooManySessions {
50 active: usize,
52 max: usize,
54 },
55 #[error("message too large: {size} bytes (max {max})")]
57 MessageTooLarge {
58 size: usize,
60 max: usize,
62 },
63 #[error("threshold {threshold} exceeds maximum {max}")]
65 ThresholdTooHigh {
66 threshold: u32,
68 max: u32,
70 },
71}
72
73pub trait Rule: Send + Sync {
76 fn evaluate(&self, request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial>;
78}
79
80pub struct PolicyEngine {
82 rules: Vec<Box<dyn Rule>>,
83}
84
85impl PolicyEngine {
86 pub fn new() -> Self {
88 Self { rules: Vec::new() }
89 }
90
91 pub fn add_rule(&mut self, rule: Box<dyn Rule>) {
93 self.rules.push(rule);
94 }
95
96 pub fn evaluate(
99 &self,
100 request: &SessionRequest,
101 ctx: &PolicyContext,
102 ) -> Result<(), PolicyDenial> {
103 for rule in &self.rules {
104 rule.evaluate(request, ctx)?;
105 }
106 Ok(())
107 }
108}
109
110impl Default for PolicyEngine {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116pub struct TimeWindowRule {
118 pub start_hour: u32,
120 pub end_hour: u32,
122}
123
124impl Rule for TimeWindowRule {
125 fn evaluate(&self, _request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial> {
126 let hour = ctx.now.hour();
127 if hour >= self.start_hour && hour < self.end_hour {
128 Ok(())
129 } else {
130 Err(PolicyDenial::OutsideTimeWindow {
131 hour,
132 start: self.start_hour,
133 end: self.end_hour,
134 })
135 }
136 }
137}
138
139pub struct MaxConcurrentSessionsRule {
141 pub max: usize,
143}
144
145impl Rule for MaxConcurrentSessionsRule {
146 fn evaluate(&self, _request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial> {
147 if ctx.quorum_active_sessions >= self.max {
148 Err(PolicyDenial::TooManySessions {
149 active: ctx.quorum_active_sessions,
150 max: self.max,
151 })
152 } else {
153 Ok(())
154 }
155 }
156}
157
158pub struct MessageSizeRule {
160 pub max_bytes: usize,
162}
163
164impl Rule for MessageSizeRule {
165 fn evaluate(&self, request: &SessionRequest, _ctx: &PolicyContext) -> Result<(), PolicyDenial> {
166 if request.message.len() > self.max_bytes {
167 Err(PolicyDenial::MessageTooLarge {
168 size: request.message.len(),
169 max: self.max_bytes,
170 })
171 } else {
172 Ok(())
173 }
174 }
175}
176
177pub struct MaxThresholdRule {
179 pub max: u32,
181}
182
183impl Rule for MaxThresholdRule {
184 fn evaluate(&self, request: &SessionRequest, _ctx: &PolicyContext) -> Result<(), PolicyDenial> {
185 if request.threshold > self.max {
186 Err(PolicyDenial::ThresholdTooHigh {
187 threshold: request.threshold,
188 max: self.max,
189 })
190 } else {
191 Ok(())
192 }
193 }
194}
195
196#[cfg(test)]
197#[allow(clippy::field_reassign_with_default)]
198mod tests {
199 use super::*;
200 use crate::coordinator::session::SessionRequest;
201
202 fn make_request(threshold: u32, message: Vec<u8>) -> SessionRequest {
203 SessionRequest {
204 quorum_id: "test-quorum".into(),
205 scheme: "CMP20".into(),
206 message,
207 threshold,
208 num_parties: 5,
209 unlock_window_minutes: 60,
210 requested_by: "tester".into(),
211 }
212 }
213
214 #[test]
215 fn empty_engine_allows_all() {
216 let engine = PolicyEngine::new();
217 let req = make_request(2, vec![0; 32]);
218 let ctx = PolicyContext::default();
219 assert!(engine.evaluate(&req, &ctx).is_ok());
220 }
221
222 #[test]
223 fn time_window_allows_during_business_hours() {
224 let rule = TimeWindowRule {
225 start_hour: 9,
226 end_hour: 17,
227 };
228 let req = make_request(2, vec![0; 32]);
229 let mut ctx = PolicyContext::default();
230 ctx.now = Utc::now().with_hour(12).unwrap();
231 assert!(rule.evaluate(&req, &ctx).is_ok());
232 }
233
234 #[test]
235 fn time_window_denies_outside_hours() {
236 let rule = TimeWindowRule {
237 start_hour: 9,
238 end_hour: 17,
239 };
240 let req = make_request(2, vec![0; 32]);
241 let mut ctx = PolicyContext::default();
242 ctx.now = Utc::now().with_hour(23).unwrap();
243 assert!(rule.evaluate(&req, &ctx).is_err());
244 }
245
246 #[test]
247 fn max_sessions_denies_when_exceeded() {
248 let rule = MaxConcurrentSessionsRule { max: 3 };
249 let req = make_request(2, vec![0; 32]);
250 let mut ctx = PolicyContext::default();
251 ctx.quorum_active_sessions = 3;
252 assert!(rule.evaluate(&req, &ctx).is_err());
253 }
254
255 #[test]
256 fn max_sessions_allows_under_limit() {
257 let rule = MaxConcurrentSessionsRule { max: 3 };
258 let req = make_request(2, vec![0; 32]);
259 let mut ctx = PolicyContext::default();
260 ctx.quorum_active_sessions = 2;
261 assert!(rule.evaluate(&req, &ctx).is_ok());
262 }
263
264 #[test]
265 fn message_size_denies_too_large() {
266 let rule = MessageSizeRule { max_bytes: 64 };
267 let req = make_request(2, vec![0; 128]);
268 let ctx = PolicyContext::default();
269 assert!(rule.evaluate(&req, &ctx).is_err());
270 }
271
272 #[test]
273 fn threshold_denies_too_high() {
274 let rule = MaxThresholdRule { max: 5 };
275 let req = make_request(7, vec![0; 32]);
276 let ctx = PolicyContext::default();
277 assert!(rule.evaluate(&req, &ctx).is_err());
278 }
279
280 #[test]
281 fn multiple_rules_first_denial_wins() {
282 let mut engine = PolicyEngine::new();
283 engine.add_rule(Box::new(MaxThresholdRule { max: 5 }));
284 engine.add_rule(Box::new(MessageSizeRule { max_bytes: 64 }));
285
286 let req = make_request(7, vec![0; 128]);
287 let ctx = PolicyContext::default();
288 let result = engine.evaluate(&req, &ctx);
289 match result {
290 Err(PolicyDenial::ThresholdTooHigh { threshold, .. }) => {
291 assert_eq!(threshold, 7);
292 }
293 _ => panic!("expected ThresholdTooHigh"),
294 }
295 }
296
297 #[test]
298 fn multiple_rules_all_pass() {
299 let mut engine = PolicyEngine::new();
300 engine.add_rule(Box::new(MaxThresholdRule { max: 10 }));
301 engine.add_rule(Box::new(MessageSizeRule { max_bytes: 1024 }));
302 engine.add_rule(Box::new(MaxConcurrentSessionsRule { max: 5 }));
303
304 let req = make_request(3, vec![0; 32]);
305 let ctx = PolicyContext {
306 quorum_active_sessions: 2,
307 ..Default::default()
308 };
309 assert!(engine.evaluate(&req, &ctx).is_ok());
310 }
311}