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