Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions crate/access/src/audit/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;

use crate::audit::hash::compute_row_hash;

/// The finalised, persisted audit event including its hash-chain fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
Expand Down Expand Up @@ -144,6 +146,29 @@ impl AuditEventDraft {
details: None,
}
}

/// Finalises this draft with the supplied chain position.
/// The resulting hash includes `prev_hash`.
#[must_use]
pub fn finalize(self, id: i64, prev_hash: [u8; 32]) -> AuditEvent {
let mut event = AuditEvent {
id,
timestamp: self.timestamp,
operation: self.operation,
user: self.user,
object_uid: self.object_uid,
algorithm: self.algorithm,
client_ip: self.client_ip,
result: self.result,
duration_ms: self.duration_ms,
request_id: self.request_id,
details: self.details,
prev_hash,
row_hash: [0_u8; 32],
};
event.row_hash = compute_row_hash(&event);
event
}
}

/// Current UTC time truncated to **microsecond** resolution.
Expand All @@ -167,6 +192,7 @@ mod tests {
use super::{
AuditEventDraft, AuditResult, OperationAuditContext, RequestAuditContext, audit_now,
};
use crate::audit::hash::verify_event;

#[test]
fn canonical_str_success() {
Expand Down Expand Up @@ -283,4 +309,52 @@ mod tests {
let ts = audit_now();
assert_eq!(ts.nanosecond() % 1_000, 0);
}

#[test]
fn finalize_assigns_chain_fields_and_verifies() {
let draft = AuditEventDraft {
timestamp: audit_now(),
operation: "Encrypt".to_owned(),
user: "alice@example.com".to_owned(),
object_uid: Some("obj-1234".to_owned()),
algorithm: Some("AES-256-GCM".to_owned()),
client_ip: Some("127.0.0.1".to_owned()),
result: AuditResult::Success,
duration_ms: 5,
request_id: None,
details: None,
};
let prev_hash = [0xAB_u8; 32];
let event = draft.finalize(7, prev_hash);

assert_eq!(event.id, 7);
assert_eq!(event.prev_hash, prev_hash);
assert_eq!(event.operation, "Encrypt");
assert!(
verify_event(&event),
"finalize() must produce a self-consistent row_hash"
);
}

#[test]
fn finalize_is_deterministic_for_identical_input() {
let draft = AuditEventDraft {
timestamp: audit_now(),
operation: "Decrypt".to_owned(),
user: "bob@example.com".to_owned(),
object_uid: None,
algorithm: None,
client_ip: None,
result: AuditResult::Success,
duration_ms: 1,
request_id: None,
details: None,
};
let a = draft.clone().finalize(0, [0_u8; 32]);
let b = draft.finalize(0, [0_u8; 32]);
assert_eq!(
a.row_hash, b.row_hash,
"same draft/id/prev_hash must yield the same canonical row_hash across backends"
);
}
}
9 changes: 9 additions & 0 deletions crate/interfaces/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ pub enum InterfaceError {
#[error("{0}")]
Default(String),

/// Wraps a `std::io::Error` with context, keeping `.kind()` inspectable — unlike
/// `Default`, which only keeps the rendered message.
#[error("{context}: {source}")]
Io {
context: String,
#[source]
source: std::io::Error,
},

#[error("Invalid Request: {0}")]
InvalidRequest(String),

Expand Down
40 changes: 13 additions & 27 deletions crate/interfaces/src/stores/audit_sink.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
//! The `AuditSink` trait: a durable destination for finalised audit events.
//! Durable storage interface for finalised audit events.
//!
//! Implemented by each backend that wants to persist the audit hash chain. A sink never
//! assigns ids and never computes hashes — it persists what it is given, in the order it
//! is given, and reports where the chain left off so the writer can resume it. Backends
//! are interchangeable at the trait boundary: a chain started on one backend can be
//! verified after export from another, because both encode the same [`AuditEvent`] and
//! the same canonical hash (see `cosmian_kms_access::audit::canonical_bytes`).
//!
//! # Recovery policy is per-backend, not part of this contract
//!
//! [`AuditSink::resume`] does not mandate a single recovery policy. A backend whose
//! storage can be torn mid-write (an appended file, killed mid-`fsync`) may recover a
//! trustworthy prefix and truncate the rest; a backend whose writes are atomic (a single
//! `INSERT`) has no torn-write case to recover from and can reasonably fail closed on
//! any tail corruption. Document the chosen policy on the implementing type, not here.
//! Each backend owns its recovery policy; the writer owns ids and hashes.

use async_trait::async_trait;
use cosmian_kms_access::audit::AuditEvent;
Expand All @@ -29,7 +16,7 @@ pub struct ChainHead {
}

impl ChainHead {
/// Seed for an empty chain: the first event gets id 0 and an all-zeros `prev_hash`.
/// Chain head before the first event.
pub const EMPTY: Self = Self {
next_id: 0,
prev_hash: [0_u8; 32],
Expand All @@ -39,20 +26,14 @@ impl ChainHead {
/// A durable destination for finalised audit events.
///
/// # Contract
/// * `write_event_atomic` : on `Ok` the event is durable; on `Err` nothing was
/// persisted. The writer relies on this — a failed write does not advance
/// `next_id`/`prev_hash`. This ensures that a half-written row does not silently fork the chain.
/// * On `write_event_atomic` success, the event is durable. On error, nothing is persisted.
/// * A sink **must never update or delete** a previously written event.
#[async_trait]
pub trait AuditSink: Send {
/// Short sink name for log messages: `"file"`, `"postgres"`.
fn name(&self) -> &'static str;

/// Reads the chain head so the writer can resume an existing log. Called exactly
/// once, before any `write_event_atomic`.
///
/// Recovery policy on a corrupted or unreadable tail is entirely up to the
/// implementation — see the module docs.
/// Recovers the backend and returns the chain head.
///
/// # Errors
/// Returns an error when the tail cannot be read, or when the implementation's own
Expand All @@ -66,11 +47,16 @@ pub trait AuditSink: Send {
/// not consider the event committed (see the trait-level contract).
async fn write_event_atomic(&mut self, event: &AuditEvent) -> InterfaceResult<()>;

/// Called once when the writer loop exits (channel closed on graceful shutdown).
/// Whether the writer should drop events without calling
/// [`Self::write_event_atomic`].
fn is_write_capacity_exceeded(&self) -> bool {
false
}

/// Performs backend-specific shutdown synchronisation.
///
/// # Errors
/// Returns an error if final synchronisation fails; the writer logs it and exits
/// regardless.
/// Returns an error if synchronisation fails.
async fn final_sync(&mut self) -> InterfaceResult<()> {
Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions crate/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ actix-session = { workspace = true, features = ["cookie-session"] }
actix-tls = { workspace = true }
actix-web = { workspace = true, features = ["macros", "openssl"] }
async-recursion = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = [
Expand Down
Loading
Loading