Skip to main content

immutable_logging/
log_entry.rs

1//! Log Entry - Structure of immutable audit log entries
2
3use 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/// Event types for audit logging
14#[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/// Actor (user/system) information
38#[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/// Request context
47#[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/// Compliance information
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Compliance {
59    pub legal_basis: String,
60    pub retention_years: u32,
61}
62
63/// Integrity metadata
64#[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/// Access decision
81#[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/// Log entry structure
97#[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/// Immutable builder for `LogEntry`.
114#[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    /// Attach a canonical ProofEnvelopeV1 payload (base64-encoded for JSON storage).
176    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    /// Create a builder for a new log entry.
221    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    /// Backward-compatible constructor using default builder values.
239    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    /// Compute full hash including previous entry hash using canonical encoding.
320    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}