mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
Reference in New Issue
Block a user