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