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().to_string().split('-').next().unwrap_or("unknown")
195 );
196
197 let mut entry = LogEntry {
198 entry_id,
199 version: "1.0".to_string(),
200 timestamp_unix,
201 timestamp_iso,
202 event_type: self.event_type,
203 actor: self.actor,
204 request: self.request,
205 compliance: self.compliance,
206 proof_envelope_v1_b64: self.proof_envelope_v1_b64,
207 integrity: Integrity {
208 content_hash: String::new(),
209 previous_entry_hash: String::new(),
210 },
211 decision: self.decision,
212 rule_id: self.rule_id,
213 };
214 entry.recompute_content_hash()?;
215 Ok(entry)
216 }
217}
218
219impl LogEntry {
220 pub fn builder(event_type: EventType, agent_id: String, agent_org: String) -> LogEntryBuilder {
222 LogEntryBuilder {
223 event_type,
224 actor: Actor {
225 agent_id,
226 agent_org,
227 mission_id: None,
228 mission_type: None,
229 },
230 request: None,
231 compliance: None,
232 proof_envelope_v1_b64: None,
233 decision: Decision::Allow,
234 rule_id: None,
235 }
236 }
237
238 pub fn new(event_type: EventType, agent_id: String, agent_org: String) -> Self {
240 Self::builder(event_type, agent_id, agent_org)
241 .build()
242 .unwrap_or_else(|_| Self {
243 entry_id: "le_invalid".to_string(),
244 version: "1.0".to_string(),
245 timestamp_unix: 0,
246 timestamp_iso: "1970-01-01T00:00:00+00:00".to_string(),
247 event_type,
248 actor: Actor {
249 agent_id: "invalid".to_string(),
250 agent_org: "invalid".to_string(),
251 mission_id: None,
252 mission_type: None,
253 },
254 request: None,
255 compliance: None,
256 proof_envelope_v1_b64: None,
257 integrity: Integrity {
258 content_hash: "0".repeat(64),
259 previous_entry_hash: String::new(),
260 },
261 decision: Decision::Allow,
262 rule_id: None,
263 })
264 }
265
266 pub fn entry_id(&self) -> &str {
267 &self.entry_id
268 }
269
270 pub fn event_type(&self) -> EventType {
271 self.event_type
272 }
273
274 pub fn decision(&self) -> Decision {
275 self.decision
276 }
277
278 pub fn proof_envelope_v1_b64(&self) -> Option<&str> {
279 self.proof_envelope_v1_b64.as_deref()
280 }
281
282 pub fn rule_id(&self) -> Option<&str> {
283 self.rule_id.as_deref()
284 }
285
286 pub fn integrity(&self) -> &Integrity {
287 &self.integrity
288 }
289
290 pub fn timestamp_iso(&self) -> &str {
291 &self.timestamp_iso
292 }
293
294 pub(crate) fn previous_entry_hash(&self) -> &str {
295 self.integrity.previous_entry_hash()
296 }
297
298 pub(crate) fn verify_content_hash(&self) -> bool {
299 match self.compute_content_hash() {
300 Ok(v) => v == self.integrity.content_hash,
301 Err(_) => false,
302 }
303 }
304
305 pub(crate) fn commit_with_previous_hash(mut self, previous_hash: &str) -> Result<Self, LogError> {
306 self.integrity.previous_entry_hash = previous_hash.to_string();
307 self.recompute_content_hash()?;
308 Ok(self)
309 }
310
311 pub(crate) fn canonical_entry_bytes(&self) -> Result<Vec<u8>, LogError> {
312 let full = CanonicalLogEntryFull {
313 content: self.canonical_content_payload(),
314 integrity: &self.integrity,
315 };
316 encode_canonical(ENTRY_SCHEMA_ID, &full)
317 }
318
319 pub fn compute_hash(&self, previous_hash: &str) -> Result<String, LogError> {
321 let commit = CanonicalLogEntryCommit {
322 entry_id: &self.entry_id,
323 content_hash: &self.integrity.content_hash,
324 previous_entry_hash: previous_hash,
325 };
326 let bytes = encode_canonical(COMMIT_SCHEMA_ID, &commit)?;
327 Ok(sha256_hex(&bytes))
328 }
329
330 fn canonical_content_payload(&self) -> CanonicalLogEntryContent<'_> {
331 CanonicalLogEntryContent {
332 entry_id: &self.entry_id,
333 version: &self.version,
334 timestamp_unix: self.timestamp_unix,
335 timestamp_iso: &self.timestamp_iso,
336 event_type: self.event_type,
337 actor: &self.actor,
338 request: &self.request,
339 compliance: &self.compliance,
340 proof_envelope_v1_b64: &self.proof_envelope_v1_b64,
341 decision: self.decision,
342 rule_id: &self.rule_id,
343 }
344 }
345
346 fn compute_content_hash(&self) -> Result<String, LogError> {
347 let content = self.canonical_content_payload();
348 let bytes = encode_canonical(CONTENT_SCHEMA_ID, &content)?;
349 Ok(sha256_hex(&bytes))
350 }
351
352 fn recompute_content_hash(&mut self) -> Result<(), LogError> {
353 self.integrity.content_hash = self.compute_content_hash()?;
354 Ok(())
355 }
356}
357
358fn sha256_hex(data: &[u8]) -> String {
359 let mut hasher = Sha256::new();
360 hasher.update(data);
361 format!("{:x}", hasher.finalize())
362}
363
364fn encode_canonical<T: Serialize>(schema_id: &str, payload: &T) -> Result<Vec<u8>, LogError> {
365 let json = serde_json::to_vec(payload)
366 .map_err(|e| LogError::SerializationError(e.to_string()))?;
367 let schema_len: u16 = schema_id
368 .len()
369 .try_into()
370 .map_err(|_| LogError::SerializationError("schema_id too long".to_string()))?;
371 let json_len: u32 = json
372 .len()
373 .try_into()
374 .map_err(|_| LogError::SerializationError("payload too long".to_string()))?;
375
376 let mut out = Vec::with_capacity(1 + 2 + schema_id.len() + 4 + json.len());
377 out.push(CANONICAL_ENCODING_VERSION);
378 out.extend_from_slice(&schema_len.to_be_bytes());
379 out.extend_from_slice(schema_id.as_bytes());
380 out.extend_from_slice(&json_len.to_be_bytes());
381 out.extend_from_slice(&json);
382 Ok(out)
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_create_entry() {
391 let entry = LogEntry::new(
392 EventType::AccountQuery,
393 "AGENT_001".to_string(),
394 "FISCALITE_DGFiP".to_string(),
395 );
396
397 assert!(entry.entry_id().starts_with("le_"));
398 assert_eq!(entry.event_type(), EventType::AccountQuery);
399 assert!(entry.verify_content_hash());
400 }
401
402 #[test]
403 fn test_compute_hash() {
404 let entry = LogEntry::new(
405 EventType::AuthSuccess,
406 "AGENT_001".to_string(),
407 "GENDARMERIE".to_string(),
408 )
409 .commit_with_previous_hash("previous_hash_123")
410 .unwrap();
411
412 let hash = entry.compute_hash("previous_hash_123").unwrap();
413 assert_eq!(hash.len(), 64);
414 }
415
416 #[test]
417 fn test_tamper_invalidates_content_hash() {
418 let mut entry = LogEntry::new(
419 EventType::RuleViolation,
420 "AGENT_001".to_string(),
421 "DGFiP".to_string(),
422 );
423 assert!(entry.verify_content_hash());
424
425 entry.decision = Decision::Block;
426 assert!(!entry.verify_content_hash());
427 }
428
429 #[test]
430 fn test_canonical_entry_bytes_have_version_and_schema_prefix() {
431 let entry = LogEntry::new(
432 EventType::DataAccess,
433 "AGENT_001".to_string(),
434 "ORG".to_string(),
435 );
436 let bytes = entry.canonical_entry_bytes().unwrap();
437 assert_eq!(bytes[0], CANONICAL_ENCODING_VERSION);
438 let schema_len = u16::from_be_bytes([bytes[1], bytes[2]]) as usize;
439 let schema = std::str::from_utf8(&bytes[3..3 + schema_len]).unwrap();
440 assert_eq!(schema, ENTRY_SCHEMA_ID);
441 }
442
443 #[test]
444 fn test_proof_envelope_attachment_is_hashed() {
445 let base = LogEntry::builder(
446 EventType::RuleViolation,
447 "AGENT_001".to_string(),
448 "ORG".to_string(),
449 )
450 .decision(Decision::Block)
451 .build()
452 .unwrap();
453
454 let with_proof = LogEntry::builder(
455 EventType::RuleViolation,
456 "AGENT_001".to_string(),
457 "ORG".to_string(),
458 )
459 .decision(Decision::Block)
460 .proof_envelope_v1_bytes(&[1, 2, 3, 4])
461 .build()
462 .unwrap();
463
464 assert_ne!(
465 base.integrity().content_hash(),
466 with_proof.integrity().content_hash()
467 );
468 assert!(with_proof.proof_envelope_v1_b64().is_some());
469 }
470}