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.get_field(&self.condition.field)
64 .ok_or_else(|| EngineError::FieldNotFound(self.condition.field.clone()))?;
65
66 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 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#[derive(Debug)]
130pub struct RuleRegistry {
131 rules: Vec<Rule>,
132 by_id: std::collections::HashMap<String, usize>,
133}
134
135impl RuleRegistry {
136 pub fn empty() -> Self {
139 RuleRegistry {
140 rules: Vec::new(),
141 by_id: std::collections::HashMap::new(),
142 }
143 }
144
145 pub fn new() -> Self {
147 let mut registry = RuleRegistry::empty();
148
149 registry.load_builtin_rules();
151
152 registry
153 }
154
155 fn load_builtin_rules(&mut self) {
157 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 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 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, },
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 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 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 pub fn get_active_rules(&self) -> Vec<&Rule> {
264 self.rules.iter().filter(|r| r.is_valid_now()).collect()
265 }
266
267 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 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}