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()
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    /// Create a builder for a new log entry.
225    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    /// Constructor using default builder values.
243    ///
244    /// This function is fail-closed: no synthetic/fallback entry is produced.
245    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    /// Compute full hash including previous entry hash using canonical encoding.
310    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}