From bccbc89c91d1a9989e3a6d06dcaaaa4469f17340 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:19:24 -0400 Subject: [PATCH] feat(audit): widen NewAuditEntry.community_id to CommunityId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the provenance fence visible in the type signature, not a per-call-site convention. `NewAuditEntry.community_id` becomes `CommunityId` (the server-resolved newtype) instead of a raw `Uuid`, so a wiring call site can no longer pass an arbitrary UUID off the event/channel being acted on — the only doors to a `CommunityId` are host resolution or a server-scoped DB row, never client input. The DB-row type `AuditEntry` stays `Uuid`: sqlx reads/writes it directly and `compute_hash` does `.as_bytes()` on it, so the stored hash bytes are byte-for-byte identical and the already-integrated chain stays valid — no migration, no re-hash. The `as_uuid()` dereference moves inside `AuditService::log` at the DB boundary, where the column is written; the advisory-lock key is unchanged (CommunityId's Display delegates to Uuid). Drop the now-orphaned `Serialize`/`Deserialize` derive (and the `#[serde(default)]` on `detail`) from `NewAuditEntry`: it has no serde consumer — it travels only through the in-process audit sink (mpsc), never a wire/DB boundary. Keeping it non-deserializable reinforces the fence: no client blob can mint a NewAuditEntry. Full package green (13/13, incl. the 6 PG isolation tests and the community_id_is_part_of_identity fence); clippy -D warnings + fmt clean. Adversarially verified the fence is non-vacuous: dropping community_id from compute_hash turns community_id_is_part_of_identity RED, restored. Co-authored-by: Dawn (sprout agent) Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-audit/src/entry.rs | 17 +++++++++++++---- crates/buzz-audit/src/service.rs | 10 +++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) 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())),