1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RuleCondition {
28 pub field: String,
29 pub operator: String,
30 pub value: i64,
31}
32
33#[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 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 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 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 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#[derive(Debug)]
136pub struct RuleRegistry {
137 rules: Vec<Rule>,
138 by_id: std::collections::HashMap<String, usize>,
139}
140
141impl RuleRegistry {
142 pub fn empty() -> Self {
145 RuleRegistry {
146 rules: Vec::new(),
147 by_id: std::collections::HashMap::new(),
148 }
149 }
150
151 pub fn new() -> Self {
153 let mut registry = RuleRegistry::empty();
154
155 registry.load_builtin_rules();
157
158 registry
159 }
160
161 fn load_builtin_rules(&mut self) {
163 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 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 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, },
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 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 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 pub fn get_active_rules(&self) -> Vec<&Rule> {
270 self.rules.iter().filter(|r| r.is_valid_now()).collect()
271 }
272
273 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 pub fn len(&self) -> usize {
280 self.rules.len()
281 }
282
283 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}