diff --git a/crates/buzz-audit/src/entry.rs b/crates/buzz-audit/src/entry.rs index 7aecac9c0..33b51f8cf 100644 --- a/crates/buzz-audit/src/entry.rs +++ b/crates/buzz-audit/src/entry.rs @@ -1,3 +1,4 @@ +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -41,10 +42,19 @@ pub struct AuditEntry { /// `community_id` is the **server-resolved** tenant (from the request's /// `TenantContext`), never a client-supplied value — the same provenance rule /// the whole multi-tenant model rests on. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// Not `Serialize`/`Deserialize`: this is an in-process input struct (consumed +/// by `AuditService::log`, threaded through the in-memory audit sink), never +/// crossing a wire or DB boundary as a whole. Keeping it non-deserializable +/// reinforces the fence — there is no path by which a client-supplied blob +/// becomes a `NewAuditEntry` (and thus a `CommunityId`). +#[derive(Debug, Clone, PartialEq, Eq)] pub struct NewAuditEntry { - /// Server-resolved community this entry belongs to. - pub community_id: Uuid, + /// Server-resolved community this entry belongs to. Typed as [`CommunityId`] + /// (not a raw `Uuid`) so the provenance rule is visible in the signature: + /// the only ways to obtain one are host resolution or a server-scoped DB + /// row — never a value parsed from client input. + pub community_id: CommunityId, /// Action that was performed. pub action: AuditAction, /// Raw bytes of the actor's Nostr pubkey, if the action has one. @@ -58,6 +68,5 @@ pub struct NewAuditEntry { /// or other secrets here. `AuthSuccess`/`AuthFailure` entries carry only /// outcome metadata — the token has no slot in this type, and `detail` must /// not become one. - #[serde(default)] pub detail: serde_json::Value, } diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 4bea30757..913131a59 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -77,13 +77,17 @@ impl AuditService { ) -> Result { let mut tx = conn.begin().await?; + // The stored row keys on the raw UUID; the typed `CommunityId` on the + // input is the provenance fence, dereferenced here at the DB boundary. + let community_id = *entry.community_id.as_uuid(); + // Head of THIS community's chain — scoped by community_id. let head = sqlx::query( "SELECT seq, hash FROM audit_log WHERE community_id = $1 ORDER BY seq DESC LIMIT 1", ) - .bind(entry.community_id) + .bind(community_id) .fetch_optional(&mut *tx) .await?; @@ -99,7 +103,7 @@ impl AuditService { let created_at: DateTime = Utc::now(); let mut audit_entry = AuditEntry { - community_id: entry.community_id, + community_id, seq, hash: Vec::new(), prev_hash, @@ -280,7 +284,7 @@ mod tests { fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry { NewAuditEntry { - community_id, + community_id: CommunityId::from_uuid(community_id), action, actor_pubkey: Some(vec![0xab; 32]), object_id: Some(format!("obj_{}", Uuid::new_v4())),