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