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!("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    /// Create a builder for a new log entry.
207    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    /// Constructor using default builder values.
225    ///
226    /// This function is fail-closed: no synthetic/fallback entry is produced.
227    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    /// Compute full hash including previous entry hash using canonical encoding.
292    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}