feat(audit): widen NewAuditEntry.community_id to CommunityId

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) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-06-26 19:02:28 -04:00
co-authored by Dawn
parent f17760b6ea
commit bccbc89c91
2 changed files with 20 additions and 7 deletions
+13 -4
View File
@@ -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,
}
+7 -3
View File
@@ -77,13 +77,17 @@ impl AuditService {
) -> Result<AuditEntry, AuditError> {
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> = 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())),