Skip to main content

confium_coordinator/coordinator/
policy.rs

1//! Policy engine — enforces access-control rules on session requests.
2//!
3//! The coordinator evaluates all registered rules before creating a
4//! signing session. If any rule denies the request, the session is
5//! rejected with a typed `PolicyDenial`.
6//!
7//! ## OCP design
8//!
9//! New rules are added by implementing the [`Rule`] trait — no existing
10//! code is modified. The [`PolicyEngine`] collects rules and evaluates
11//! them in order.
12
13use crate::coordinator::session::SessionRequest;
14use chrono::{DateTime, Timelike, Utc};
15
16/// Context provided to rules during evaluation.
17#[derive(Debug, Clone)]
18pub struct PolicyContext {
19    /// Current time (injectable for testing).
20    pub now: DateTime<Utc>,
21    /// Active session count for the requesting quorum.
22    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/// Why a policy denied a request.
35#[derive(Debug, Clone, thiserror::Error)]
36pub enum PolicyDenial {
37    /// Request falls outside the allowed time window.
38    #[error("request outside allowed hours: {hour:02}:00 (allowed {start:02}:00–{end:02}:00)")]
39    OutsideTimeWindow {
40        /// The hour that was denied.
41        hour: u32,
42        /// Allowed start hour (inclusive).
43        start: u32,
44        /// Allowed end hour (exclusive).
45        end: u32,
46    },
47    /// Too many concurrent sessions for this quorum.
48    #[error("too many concurrent sessions: {active} (max {max})")]
49    TooManySessions {
50        /// Active session count.
51        active: usize,
52        /// Maximum allowed.
53        max: usize,
54    },
55    /// Message exceeds size limit.
56    #[error("message too large: {size} bytes (max {max})")]
57    MessageTooLarge {
58        /// Actual size.
59        size: usize,
60        /// Maximum allowed.
61        max: usize,
62    },
63    /// Threshold exceeds maximum allowed.
64    #[error("threshold {threshold} exceeds maximum {max}")]
65    ThresholdTooHigh {
66        /// Requested threshold.
67        threshold: u32,
68        /// Maximum allowed.
69        max: u32,
70    },
71}
72
73/// A single policy rule. Implementations decide whether to allow
74/// or deny a session request based on the request and context.
75pub trait Rule: Send + Sync {
76    /// Evaluate the rule. Returns `Ok(())` if allowed, `Err(PolicyDenial)` if denied.
77    fn evaluate(&self, request: &SessionRequest, ctx: &PolicyContext) -> Result<(), PolicyDenial>;
78}
79
80/// Policy engine — evaluates a collection of rules.
81pub struct PolicyEngine {
82    rules: Vec<Box<dyn Rule>>,
83}
84
85impl PolicyEngine {
86    /// Create an empty policy engine.
87    pub fn new() -> Self {
88        Self { rules: Vec::new() }
89    }
90
91    /// Add a rule to the engine.
92    pub fn add_rule(&mut self, rule: Box<dyn Rule>) {
93        self.rules.push(rule);
94    }
95
96    /// Evaluate all rules. Returns the first denial encountered, or
97    /// `Ok(())` if all rules pass.
98    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
116/// Allow signing only during the specified hour range (UTC).
117pub struct TimeWindowRule {
118    /// Start hour (inclusive), 0–23.
119    pub start_hour: u32,
120    /// End hour (exclusive), 1–24.
121    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
139/// Limit concurrent sessions per quorum.
140pub struct MaxConcurrentSessionsRule {
141    /// Maximum simultaneous active sessions.
142    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
158/// Enforce a maximum message size.
159pub struct MessageSizeRule {
160    /// Maximum message size in bytes.
161    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
177/// Enforce a maximum threshold.
178pub struct MaxThresholdRule {
179    /// Maximum allowed threshold.
180    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}