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, 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/// Actor (user/system) information
33#[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/// Request context
42#[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/// Compliance information
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Compliance {
54    pub legal_basis: String,
55    pub retention_years: u32,
56}
57
58/// Integrity metadata
59#[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/// Access decision
76#[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/// Log entry structure
87#[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/// Immutable builder for `LogEntry`.
104#[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    /// Attach a canonical ProofEnvelopeV1 payload (base64-encoded for JSON storage).
166    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    /// Create a builder for a new log entry.
215    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    /// Constructor using default builder values.
233    ///
234    /// This function is fail-closed: no synthetic/fallback entry is produced.
235    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    /// Compute full hash including previous entry hash using canonical encoding.
300    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}