1use crate::error::LogError;
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8const CANONICAL_ENCODING_VERSION: u8 = 1;
9const CONTENT_SCHEMA_ID: &str = "rsrp.ledger.log_entry.content.v1";
10const ENTRY_SCHEMA_ID: &str = "rsrp.ledger.log_entry.full.v1";
11const COMMIT_SCHEMA_ID: &str = "rsrp.ledger.log_entry.commit.v1";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "UPPERCASE")]
16pub enum EventType {
17 AccountQuery,
18 AuthSuccess,
19 AuthFailure,
20 SessionStart,
21 SessionEnd,
22 RuleViolation,
23 AnomalyDetected,
24 TokenRevoked,
25 MissionCreated,
26 MissionExpired,
27 ExportRequested,
28 DataAccess,
29}
30
31impl Default for EventType {
32 fn default() -> Self {
33 EventType::DataAccess
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Actor {
40 pub agent_id: String,
41 pub agent_org: String,
42 pub mission_id: Option<String>,
43 pub mission_type: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct RequestContext {
49 pub query_type: Option<String>,
50 pub justification: Option<String>,
51 pub result_count: Option<u32>,
52 pub ip_address: Option<String>,
53 pub user_agent: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Compliance {
59 pub legal_basis: String,
60 pub retention_years: u32,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Integrity {
66 content_hash: String,
67 previous_entry_hash: String,
68}
69
70impl Integrity {
71 pub fn content_hash(&self) -> &str {
72 &self.content_hash
73 }
74
75 pub fn previous_entry_hash(&self) -> &str {
76 &self.previous_entry_hash
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "UPPERCASE")]
83pub enum Decision {
84 Allow,
85 Block,
86 Warn,
87 ApprovalRequired,
88}
89
90impl Default for Decision {
91 fn default() -> Self {
92 Decision::Allow
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct LogEntry {
99 entry_id: String,
100 version: String,
101 timestamp_unix: i64,
102 timestamp_iso: String,
103 event_type: EventType,
104 actor: Actor,
105 request: Option<RequestContext>,
106 compliance: Option<Compliance>,
107 proof_envelope_v1_b64: Option<String>,
108 integrity: Integrity,
109 decision: Decision,
110 rule_id: Option<String>,
111}
112
113#[derive(Debug, Clone)]
115pub struct LogEntryBuilder {
116 event_type: EventType,
117 actor: Actor,
118 request: Option<RequestContext>,
119 compliance: Option<Compliance>,
120 proof_envelope_v1_b64: Option<String>,
121 decision: Decision,
122 rule_id: Option<String>,
123}
124
125#[derive(Serialize)]
126struct CanonicalLogEntryContent<'a> {
127 entry_id: &'a str,
128 version: &'a str,
129 timestamp_unix: i64,
130 timestamp_iso: &'a str,
131 event_type: EventType,
132 actor: &'a Actor,
133 request: &'a Option<RequestContext>,
134 compliance: &'a Option<Compliance>,
135 proof_envelope_v1_b64: &'a Option<String>,
136 decision: Decision,
137 rule_id: &'a Option<String>,
138}
139
140#[derive(Serialize)]
141struct CanonicalLogEntryFull<'a> {
142 content: CanonicalLogEntryContent<'a>,
143 integrity: &'a Integrity,
144}
145
146#[derive(Serialize)]
147struct CanonicalLogEntryCommit<'a> {
148 entry_id: &'a str,
149 content_hash: &'a str,
150 previous_entry_hash: &'a str,
151}
152
153impl LogEntryBuilder {
154 pub fn mission(mut self, mission_id: Option<String>, mission_type: Option<String>) -> Self {
155 self.actor.mission_id = mission_id;
156 self.actor.mission_type = mission_type;
157 self
158 }
159
160 pub fn request(mut self, request: RequestContext) -> Self {
161 self.request = Some(request);
162 self
163 }
164
165 pub fn compliance(mut self, compliance: Compliance) -> Self {
166 self.compliance = Some(compliance);
167 self
168 }
169
170 pub fn decision(mut self, decision: Decision) -> Self {
171 self.decision = decision;
172 self
173 }
174
175 pub fn proof_envelope_v1_bytes(mut self, bytes: &[u8]) -> Self {
177 use base64::Engine as _;
178 self.proof_envelope_v1_b64 = Some(base64::engine::general_purpose::STANDARD.encode(bytes));
179 self
180 }
181
182 pub fn rule_id(mut self, rule_id: impl Into<String>) -> Self {
183 self.rule_id = Some(rule_id.into());
184 self
185 }
186
187 pub fn build(self) -> Result<LogEntry, LogError> {
188 let timestamp = Utc::now();
189 let timestamp_unix = timestamp.timestamp();
190 let timestamp_iso = timestamp.to_rfc3339();
191 let entry_id = format!(
192 "le_{}_{}",
193 timestamp_unix,
194 uuid::Uuid::new_v4()
195 .to_string()
196 .split('-')
197 .next()
198 .unwrap_or("unknown")
199 );
200
201 let mut entry = LogEntry {
202 entry_id,
203 version: "1.0".to_string(),
204 timestamp_unix,
205 timestamp_iso,
206 event_type: self.event_type,
207 actor: self.actor,
208 request: self.request,
209 compliance: self.compliance,
210 proof_envelope_v1_b64: self.proof_envelope_v1_b64,
211 integrity: Integrity {
212 content_hash: String::new(),
213 previous_entry_hash: String::new(),
214 },
215 decision: self.decision,
216 rule_id: self.rule_id,
217 };
218 entry.recompute_content_hash()?;
219 Ok(entry)
220 }
221}
222
223impl LogEntry {
224 pub fn builder(event_type: EventType, agent_id: String, agent_org: String) -> LogEntryBuilder {
226 LogEntryBuilder {
227 event_type,
228 actor: Actor {
229 agent_id,
230 agent_org,
231 mission_id: None,
232 mission_type: None,
233 },
234 request: None,
235 compliance: None,
236 proof_envelope_v1_b64: None,
237 decision: Decision::Allow,
238 rule_id: None,
239 }
240 }
241
242 pub fn new(
246 event_type: EventType,
247 agent_id: String,
248 agent_org: String,
249 ) -> Result<Self, LogError> {
250 Self::builder(event_type, agent_id, agent_org).build()
251 }
252
253 pub fn entry_id(&self) -> &str {
254 &self.entry_id
255 }
256
257 pub fn event_type(&self) -> EventType {
258 self.event_type
259 }
260
261 pub fn decision(&self) -> Decision {
262 self.decision
263 }
264
265 pub fn proof_envelope_v1_b64(&self) -> Option<&str> {
266 self.proof_envelope_v1_b64.as_deref()
267 }
268
269 pub fn rule_id(&self) -> Option<&str> {
270 self.rule_id.as_deref()
271 }
272
273 pub fn integrity(&self) -> &Integrity {
274 &self.integrity
275 }
276
277 pub fn timestamp_iso(&self) -> &str {
278 &self.timestamp_iso
279 }
280
281 pub(crate) fn previous_entry_hash(&self) -> &str {
282 self.integrity.previous_entry_hash()
283 }
284
285 pub(crate) fn verify_content_hash(&self) -> bool {
286 match self.compute_content_hash() {
287 Ok(v) => v == self.integrity.content_hash,
288 Err(_) => false,
289 }
290 }
291
292 pub(crate) fn commit_with_previous_hash(
293 mut self,
294 previous_hash: &str,
295 ) -> Result<Self, LogError> {
296 self.integrity.previous_entry_hash = previous_hash.to_string();
297 self.recompute_content_hash()?;
298 Ok(self)
299 }
300
301 pub(crate) fn canonical_entry_bytes(&self) -> Result<Vec<u8>, LogError> {
302 let full = CanonicalLogEntryFull {
303 content: self.canonical_content_payload(),
304 integrity: &self.integrity,
305 };
306 encode_canonical(ENTRY_SCHEMA_ID, &full)
307 }
308
309 pub fn compute_hash(&self, previous_hash: &str) -> Result<String, LogError> {
311 let commit = CanonicalLogEntryCommit {
312 entry_id: &self.entry_id,
313 content_hash: &self.integrity.content_hash,
314 previous_entry_hash: previous_hash,
315 };
316 let bytes = encode_canonical(COMMIT_SCHEMA_ID, &commit)?;
317 Ok(sha256_hex(&bytes))
318 }
319
320 fn canonical_content_payload(&self) -> CanonicalLogEntryContent<'_> {
321 CanonicalLogEntryContent {
322 entry_id: &self.entry_id,
323 version: &self.version,
324 timestamp_unix: self.timestamp_unix,
325 timestamp_iso: &self.timestamp_iso,
326 event_type: self.event_type,
327 actor: &self.actor,
328 request: &self.request,
329 compliance: &self.compliance,
330 proof_envelope_v1_b64: &self.proof_envelope_v1_b64,
331 decision: self.decision,
332 rule_id: &self.rule_id,
333 }
334 }
335
336 fn compute_content_hash(&self) -> Result<String, LogError> {
337 let content = self.canonical_content_payload();
338 let bytes = encode_canonical(CONTENT_SCHEMA_ID, &content)?;
339 Ok(sha256_hex(&bytes))
340 }
341
342 fn recompute_content_hash(&mut self) -> Result<(), LogError> {
343 self.integrity.content_hash = self.compute_content_hash()?;
344 Ok(())
345 }
346}
347
348fn sha256_hex(data: &[u8]) -> String {
349 let mut hasher = Sha256::new();
350 hasher.update(data);
351 format!("{:x}", hasher.finalize())
352}
353
354fn encode_canonical<T: Serialize>(schema_id: &str, payload: &T) -> Result<Vec<u8>, LogError> {
355 let json =
356 serde_json::to_vec(payload).map_err(|e| LogError::SerializationError(e.to_string()))?;
357 let schema_len: u16 = schema_id
358 .len()
359 .try_into()
360 .map_err(|_| LogError::SerializationError("schema_id too long".to_string()))?;
361 let json_len: u32 = json
362 .len()
363 .try_into()
364 .map_err(|_| LogError::SerializationError("payload too long".to_string()))?;
365
366 let mut out = Vec::with_capacity(1 + 2 + schema_id.len() + 4 + json.len());
367 out.push(CANONICAL_ENCODING_VERSION);
368 out.extend_from_slice(&schema_len.to_be_bytes());
369 out.extend_from_slice(schema_id.as_bytes());
370 out.extend_from_slice(&json_len.to_be_bytes());
371 out.extend_from_slice(&json);
372 Ok(out)
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn test_create_entry() {
381 let entry = LogEntry::new(
382 EventType::AccountQuery,
383 "AGENT_001".to_string(),
384 "FISCALITE_DGFiP".to_string(),
385 )
386 .unwrap();
387
388 assert!(entry.entry_id().starts_with("le_"));
389 assert_eq!(entry.event_type(), EventType::AccountQuery);
390 assert!(entry.verify_content_hash());
391 }
392
393 #[test]
394 fn test_compute_hash() {
395 let entry = LogEntry::new(
396 EventType::AuthSuccess,
397 "AGENT_001".to_string(),
398 "GENDARMERIE".to_string(),
399 )
400 .unwrap()
401 .commit_with_previous_hash("previous_hash_123")
402 .unwrap();
403
404 let hash = entry.compute_hash("previous_hash_123").unwrap();
405 assert_eq!(hash.len(), 64);
406 }
407
408 #[test]
409 fn test_tamper_invalidates_content_hash() {
410 let mut entry = LogEntry::new(
411 EventType::RuleViolation,
412 "AGENT_001".to_string(),
413 "DGFiP".to_string(),
414 )
415 .unwrap();
416 assert!(entry.verify_content_hash());
417
418 entry.decision = Decision::Block;
419 assert!(!entry.verify_content_hash());
420 }
421
422 #[test]
423 fn test_canonical_entry_bytes_have_version_and_schema_prefix() {
424 let entry = LogEntry::new(
425 EventType::DataAccess,
426 "AGENT_001".to_string(),
427 "ORG".to_string(),
428 )
429 .unwrap();
430 let bytes = entry.canonical_entry_bytes().unwrap();
431 assert_eq!(bytes[0], CANONICAL_ENCODING_VERSION);
432 let schema_len = u16::from_be_bytes([bytes[1], bytes[2]]) as usize;
433 let schema = std::str::from_utf8(&bytes[3..3 + schema_len]).unwrap();
434 assert_eq!(schema, ENTRY_SCHEMA_ID);
435 }
436
437 #[test]
438 fn test_proof_envelope_attachment_is_hashed() {
439 let base = LogEntry::builder(
440 EventType::RuleViolation,
441 "AGENT_001".to_string(),
442 "ORG".to_string(),
443 )
444 .decision(Decision::Block)
445 .build()
446 .unwrap();
447
448 let with_proof = LogEntry::builder(
449 EventType::RuleViolation,
450 "AGENT_001".to_string(),
451 "ORG".to_string(),
452 )
453 .decision(Decision::Block)
454 .proof_envelope_v1_bytes(&[1, 2, 3, 4])
455 .build()
456 .unwrap();
457
458 assert_ne!(
459 base.integrity().content_hash(),
460 with_proof.integrity().content_hash()
461 );
462 assert!(with_proof.proof_envelope_v1_b64().is_some());
463 }
464}