Skip to main content

crue_engine/
rules.rs

1//! Rule Registry and Built-in Rules
2
3use crate::context::EvaluationContext;
4use crate::decision::ActionResult;
5use crate::error::EngineError;
6use crate::ir::{ActionKind, Operator};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// Rule definition
11#[derive(Debug, Clone)]
12pub struct Rule {
13    pub id: String,
14    pub version: String,
15    pub name: String,
16    pub description: String,
17    pub severity: String,
18    pub condition: RuleCondition,
19    pub action: RuleAction,
20    pub valid_from: DateTime<Utc>,
21    pub valid_until: Option<DateTime<Utc>>,
22    pub enabled: bool,
23}
24
25/// Rule condition
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RuleCondition {
28    pub field: String,
29    pub operator: String,
30    pub value: i64,
31}
32
33/// Rule action
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RuleAction {
36    pub action_type: String,
37    pub error_code: Option<String>,
38    pub message: Option<String>,
39    pub timeout_minutes: Option<u32>,
40    pub alert_soc: bool,
41}
42
43impl Rule {
44    /// Check if rule is valid now
45    pub fn is_valid_now(&self) -> bool {
46        let now = Utc::now();
47        
48        if now < self.valid_from {
49            return false;
50        }
51        
52        if let Some(until) = self.valid_until {
53            if now > until {
54                return false;
55            }
56        }
57        
58        self.enabled
59    }
60    
61    /// Evaluate condition against context
62    pub fn evaluate(&self, ctx: &EvaluationContext) -> Result<bool, EngineError> {
63        let field_value = ctx.get_field(&self.condition.field)
64            .ok_or_else(|| EngineError::FieldNotFound(self.condition.field.clone()))?;
65        
66        // Get numeric value from field
67        let field_num = match field_value {
68            crate::context::FieldValue::Number(n) => *n,
69            crate::context::FieldValue::Boolean(b) => if *b { 1 } else { 0 },
70            _ => return Err(EngineError::TypeMismatch(self.condition.field.clone())),
71        };
72        
73        let op = self.condition.operator_typed()?;
74        let result = match op {
75            Operator::Gt => field_num > self.condition.value,
76            Operator::Lt => field_num < self.condition.value,
77            Operator::Gte => field_num >= self.condition.value,
78            Operator::Lte => field_num <= self.condition.value,
79            Operator::Eq => field_num == self.condition.value,
80            Operator::Ne => field_num != self.condition.value,
81        };
82        
83        Ok(result)
84    }
85    
86    /// Apply action
87    pub fn apply_action(&self, _ctx: &EvaluationContext) -> ActionResult {
88        match self.action.action_kind().unwrap_or(ActionKind::Log) {
89            ActionKind::Block => {
90                let mut result = ActionResult::block(
91                    self.action.error_code.as_deref().unwrap_or("UNKNOWN"),
92                    self.action.message.as_deref().unwrap_or("Access denied"),
93                );
94                if self.action.alert_soc {
95                    result = result.with_soc_alert();
96                }
97                result
98            }
99            ActionKind::Warn => {
100                ActionResult::warn(
101                    self.action.error_code.as_deref().unwrap_or("WARNING"),
102                    self.action.message.as_deref().unwrap_or("Warning"),
103                )
104            }
105            ActionKind::RequireApproval => {
106                ActionResult::approval_required(
107                    self.action.error_code.as_deref().unwrap_or("APPROVAL_REQUIRED"),
108                    self.action.timeout_minutes.unwrap_or(30),
109                )
110            }
111            ActionKind::Log => ActionResult::allow(),
112        }
113    }
114}
115
116impl RuleCondition {
117    pub fn operator_typed(&self) -> Result<Operator, EngineError> {
118        Operator::parse(&self.operator)
119    }
120}
121
122impl RuleAction {
123    pub fn action_kind(&self) -> Result<ActionKind, EngineError> {
124        ActionKind::parse(&self.action_type)
125    }
126}
127
128/// Rule registry
129#[derive(Debug)]
130pub struct RuleRegistry {
131    rules: Vec<Rule>,
132    by_id: std::collections::HashMap<String, usize>,
133}
134
135impl RuleRegistry {
136    /// Create an empty registry (without built-in rules).
137    /// Useful for tests that need deterministic "no rule matched" behavior.
138    pub fn empty() -> Self {
139        RuleRegistry {
140            rules: Vec::new(),
141            by_id: std::collections::HashMap::new(),
142        }
143    }
144
145    /// Create new registry
146    pub fn new() -> Self {
147        let mut registry = RuleRegistry::empty();
148        
149        // Load built-in rules from specification
150        registry.load_builtin_rules();
151        
152        registry
153    }
154    
155    /// Load built-in rules
156    fn load_builtin_rules(&mut self) {
157        // CRUE-001: Volume max
158        self.add_rule(Rule {
159            id: "CRUE_001".to_string(),
160            version: "1.2.0".to_string(),
161            name: "VOLUME_MAX".to_string(),
162            description: "Max 50 requêtes/heure".to_string(),
163            severity: "HIGH".to_string(),
164            condition: RuleCondition {
165                field: "agent.requests_last_hour".to_string(),
166                operator: ">=".to_string(),
167                value: 50,
168            },
169            action: RuleAction {
170                action_type: "BLOCK".to_string(),
171                error_code: Some("VOLUME_EXCEEDED".to_string()),
172                message: Some("Quota de consultation dépassé (50/h)".to_string()),
173                timeout_minutes: None,
174                alert_soc: true,
175            },
176            valid_from: Utc::now(),
177            valid_until: None,
178            enabled: true,
179        });
180        
181        // CRUE-002: Justification obligatoire
182        self.add_rule(Rule {
183            id: "CRUE_002".to_string(),
184            version: "1.1.0".to_string(),
185            name: "JUSTIFICATION_OBLIG".to_string(),
186            description: "Justification texte requise".to_string(),
187            severity: "HIGH".to_string(),
188            condition: RuleCondition {
189                field: "request.justification_length".to_string(),
190                operator: "<".to_string(),
191                value: 10,
192            },
193            action: RuleAction {
194                action_type: "BLOCK".to_string(),
195                error_code: Some("JUSTIFICATION_REQUIRED".to_string()),
196                message: Some("Justification obligatoire (min 10 caractères)".to_string()),
197                timeout_minutes: None,
198                alert_soc: false,
199            },
200            valid_from: Utc::now(),
201            valid_until: None,
202            enabled: true,
203        });
204        
205        // CRUE-003: Export interdit
206        self.add_rule(Rule {
207            id: "CRUE_003".to_string(),
208            version: "2.0.0".to_string(),
209            name: "EXPORT_INTERDIT".to_string(),
210            description: "Pas d'export CSV/XML/JSON bulk".to_string(),
211            severity: "CRITICAL".to_string(),
212            condition: RuleCondition {
213                field: "request.export_format".to_string(),
214                operator: "!=".to_string(),
215                value: 0, // Not empty
216            },
217            action: RuleAction {
218                action_type: "BLOCK".to_string(),
219                error_code: Some("EXPORT_FORBIDDEN".to_string()),
220                message: Some("Export de masse non autorisé".to_string()),
221                timeout_minutes: None,
222                alert_soc: true,
223            },
224            valid_from: Utc::now(),
225            valid_until: None,
226            enabled: true,
227        });
228        
229        // CRUE-007: Temps requête max
230        self.add_rule(Rule {
231            id: "CRUE_007".to_string(),
232            version: "1.0.0".to_string(),
233            name: "TEMPS_REQUETE".to_string(),
234            description: "Max 10 secondes".to_string(),
235            severity: "MEDIUM".to_string(),
236            condition: RuleCondition {
237                field: "context.request_hour".to_string(),
238                operator: ">=".to_string(),
239                value: 0,
240            },
241            action: RuleAction {
242                action_type: "WARN".to_string(),
243                error_code: Some("PERFORMANCE_WARNING".to_string()),
244                message: Some("Temps de requête élevé".to_string()),
245                timeout_minutes: None,
246                alert_soc: false,
247            },
248            valid_from: Utc::now(),
249            valid_until: None,
250            enabled: true,
251        });
252    }
253    
254    /// Add rule to registry
255    pub fn add_rule(&mut self, rule: Rule) {
256        let id = rule.id.clone();
257        let index = self.rules.len();
258        self.rules.push(rule);
259        self.by_id.insert(id, index);
260    }
261    
262    /// Get active rules
263    pub fn get_active_rules(&self) -> Vec<&Rule> {
264        self.rules.iter().filter(|r| r.is_valid_now()).collect()
265    }
266    
267    /// Get rule by ID
268    pub fn get_rule(&self, id: &str) -> Option<&Rule> {
269        self.by_id.get(id).and_then(|&i| self.rules.get(i))
270    }
271    
272    /// Get rule count
273    pub fn len(&self) -> usize {
274        self.rules.len()
275    }
276}
277
278impl Default for RuleRegistry {
279    fn default() -> Self {
280        Self::new()
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    
288    #[test]
289    fn test_registry_loads_builtin() {
290        let registry = RuleRegistry::new();
291        assert!(registry.len() > 0);
292    }
293    
294    #[test]
295    fn test_rule_evaluation() {
296        let rule = Rule {
297            id: "TEST_001".to_string(),
298            version: "1.0.0".to_string(),
299            name: "Test Rule".to_string(),
300            description: "Test".to_string(),
301            severity: "HIGH".to_string(),
302            condition: RuleCondition {
303                field: "agent.requests_last_hour".to_string(),
304                operator: ">=".to_string(),
305                value: 50,
306            },
307            action: RuleAction {
308                action_type: "BLOCK".to_string(),
309                error_code: Some("VOLUME_EXCEEDED".to_string()),
310                message: None,
311                timeout_minutes: None,
312                alert_soc: false,
313            },
314            valid_from: Utc::now(),
315            valid_until: None,
316            enabled: true,
317        };
318        
319        let ctx = EvaluationContext::from_request(&crate::EvaluationRequest {
320            request_id: "test".to_string(),
321            agent_id: "AGENT_001".to_string(),
322            agent_org: "DGFiP".to_string(),
323            agent_level: "standard".to_string(),
324            mission_id: None,
325            mission_type: None,
326            query_type: None,
327            justification: None,
328            export_format: None,
329            result_limit: None,
330            requests_last_hour: 60,
331            requests_last_24h: 100,
332            results_last_query: 5,
333            account_department: None,
334            allowed_departments: vec![],
335            request_hour: 14,
336            is_within_mission_hours: true,
337        });
338        
339        let result = rule.evaluate(&ctx).unwrap();
340        assert!(result);
341    }
342
343    #[test]
344    fn test_rule_condition_operator_typed() {
345        let cond = RuleCondition {
346            field: "agent.requests_last_hour".to_string(),
347            operator: ">=".to_string(),
348            value: 50,
349        };
350        assert_eq!(cond.operator_typed().unwrap(), crate::ir::Operator::Gte);
351    }
352
353    #[test]
354    fn test_rule_action_kind_typed() {
355        let action = RuleAction {
356            action_type: "BLOCK".to_string(),
357            error_code: None,
358            message: None,
359            timeout_minutes: None,
360            alert_soc: false,
361        };
362        assert_eq!(action.action_kind().unwrap(), ActionKind::Block);
363    }
364}