feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "buzz-audit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Hash-chain audit log for Buzz"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -0,0 +1,100 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Audit action recorded for each event in the log.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuditAction {
|
||||
/// A Nostr event was created.
|
||||
EventCreated,
|
||||
/// A Nostr event was deleted.
|
||||
EventDeleted,
|
||||
/// A channel was created.
|
||||
ChannelCreated,
|
||||
/// A channel's metadata was updated.
|
||||
ChannelUpdated,
|
||||
/// A channel was deleted.
|
||||
ChannelDeleted,
|
||||
/// A member was added to a channel.
|
||||
MemberAdded,
|
||||
/// A member was removed from a channel.
|
||||
MemberRemoved,
|
||||
/// A client successfully authenticated.
|
||||
AuthSuccess,
|
||||
/// A client authentication attempt failed.
|
||||
AuthFailure,
|
||||
/// A client exceeded the rate limit.
|
||||
RateLimitExceeded,
|
||||
/// A media file was uploaded via the Blossom endpoint.
|
||||
MediaUploaded,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
/// Stable string representation used in hash computation and DB storage.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::EventCreated => "event_created",
|
||||
Self::EventDeleted => "event_deleted",
|
||||
Self::ChannelCreated => "channel_created",
|
||||
Self::ChannelUpdated => "channel_updated",
|
||||
Self::ChannelDeleted => "channel_deleted",
|
||||
Self::MemberAdded => "member_added",
|
||||
Self::MemberRemoved => "member_removed",
|
||||
Self::AuthSuccess => "auth_success",
|
||||
Self::AuthFailure => "auth_failure",
|
||||
Self::RateLimitExceeded => "rate_limit_exceeded",
|
||||
Self::MediaUploaded => "media_uploaded",
|
||||
}
|
||||
}
|
||||
|
||||
const ALL: &'static [Self] = &[
|
||||
Self::EventCreated,
|
||||
Self::EventDeleted,
|
||||
Self::ChannelCreated,
|
||||
Self::ChannelUpdated,
|
||||
Self::ChannelDeleted,
|
||||
Self::MemberAdded,
|
||||
Self::MemberRemoved,
|
||||
Self::AuthSuccess,
|
||||
Self::AuthFailure,
|
||||
Self::RateLimitExceeded,
|
||||
Self::MediaUploaded,
|
||||
];
|
||||
}
|
||||
|
||||
impl fmt::Display for AuditAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AuditAction {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::ALL
|
||||
.iter()
|
||||
.find(|a| a.as_str() == s)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("unknown audit action: {s:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_all_variants() {
|
||||
for action in AuditAction::ALL {
|
||||
let parsed: AuditAction = action.to_string().parse().unwrap();
|
||||
assert_eq!(&parsed, action);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_action_returns_err() {
|
||||
assert!("totally_bogus".parse::<AuditAction>().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use buzz_core::CommunityId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::action::AuditAction;
|
||||
|
||||
/// A materialised audit log entry as stored in `audit_log`.
|
||||
///
|
||||
/// Rows are keyed `(community_id, seq)`: `seq` is monotonic *within one
|
||||
/// community*, and `prev_hash` chains to the previous entry *of the same
|
||||
/// community*. The chain is independent per tenant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuditEntry {
|
||||
/// Server-resolved community this entry belongs to. Leads the primary key.
|
||||
pub community_id: Uuid,
|
||||
/// Sequence number, monotonic within `community_id` (starts at 1).
|
||||
pub seq: i64,
|
||||
/// SHA-256 of this entry's fields including `community_id` and `prev_hash`.
|
||||
pub hash: Vec<u8>,
|
||||
/// SHA-256 of the previous entry in *this community's* chain, or `None` for
|
||||
/// the community's first entry (hashed as [`crate::hash::GENESIS_HASH`]).
|
||||
pub prev_hash: Option<Vec<u8>>,
|
||||
/// Action that was performed.
|
||||
pub action: AuditAction,
|
||||
/// Raw bytes of the actor's Nostr pubkey, if the action has one.
|
||||
pub actor_pubkey: Option<Vec<u8>>,
|
||||
/// Generic identifier of the object acted upon (event id hex, channel UUID,
|
||||
/// media sha256, …), if any. The relay resolves it under `community_id`;
|
||||
/// it never names an object in another community.
|
||||
pub object_id: Option<String>,
|
||||
/// Arbitrary JSON context. **Included in the hash** (serialized with sorted
|
||||
/// keys for determinism) so tampering with it is detectable.
|
||||
pub detail: serde_json::Value,
|
||||
/// When the entry was recorded.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Input for appending a new audit entry. `seq`, `prev_hash`, `hash`, and
|
||||
/// `created_at` are assigned by [`crate::service::AuditService::log`].
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// 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. 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.
|
||||
pub actor_pubkey: Option<Vec<u8>>,
|
||||
/// Generic identifier of the object acted upon, if any.
|
||||
pub object_id: Option<String>,
|
||||
/// Arbitrary JSON context included in the hash.
|
||||
///
|
||||
/// **Never bearer-token material.** This field is opaque to the audit
|
||||
/// crate and persisted verbatim; callers must not write tokens, passwords,
|
||||
/// 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.
|
||||
pub detail: serde_json::Value,
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during audit log operations.
|
||||
///
|
||||
/// These are **operator-internal** diagnostics (logged by the audit worker, or
|
||||
/// returned to an operator-scoped verification call) — they are never relayed to
|
||||
/// a client on the wire. Even so, no variant embeds a `community_id` or any
|
||||
/// cross-community object identifier: a `seq` is per-community and meaningless
|
||||
/// without its chain, and hashes are opaque. An error raised while verifying
|
||||
/// community A's chain therefore cannot reveal a fact about community B.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuditError {
|
||||
/// A database operation failed.
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
/// The `prev_hash` of an entry does not match the hash of the preceding
|
||||
/// entry in the same community's chain.
|
||||
#[error(
|
||||
"hash chain integrity violation at seq {seq}: prev_hash does not match preceding entry"
|
||||
)]
|
||||
ChainViolation {
|
||||
/// Per-community sequence number of the offending entry.
|
||||
seq: i64,
|
||||
},
|
||||
|
||||
/// The stored hash of an entry does not match the recomputed hash.
|
||||
#[error("hash mismatch at seq {seq}: stored hash does not match recomputed hash")]
|
||||
HashMismatch {
|
||||
/// Per-community sequence number of the offending entry.
|
||||
seq: i64,
|
||||
},
|
||||
|
||||
/// An unrecognised action string was found in the database.
|
||||
#[error("unknown audit action in database")]
|
||||
UnknownAction,
|
||||
|
||||
/// A JSON serialization error occurred (e.g. while canonicalising `detail`).
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sanitization obligation for the conformance `audit_log` row: an error
|
||||
/// raised while verifying or appending to one community's chain must not let
|
||||
/// its rendered text become a cross-community identifier — no `community_id`,
|
||||
/// no constraint name. Only `seq` may appear, and `seq` is per-community and
|
||||
/// meaningless without the chain it indexes.
|
||||
///
|
||||
/// This is the *complement* to the structural fence in the variant
|
||||
/// definitions above: those variants simply have no `community_id` field, so
|
||||
/// there is no slot to leak one from. This test pins the observable form —
|
||||
/// if anyone adds a `community_id` to a variant and threads it into the
|
||||
/// `#[error(...)]` format string, the assertion below reds.
|
||||
#[test]
|
||||
fn audit_error_text_carries_no_community_id_or_constraint() {
|
||||
// A concrete community whose chain is "being verified" when these errors
|
||||
// fire. If its id leaked into any error text, the error would identify a
|
||||
// specific tenant.
|
||||
let community = uuid::Uuid::new_v4();
|
||||
let community_str = community.to_string();
|
||||
let community_simple = community.simple().to_string();
|
||||
|
||||
// The variants the audit crate constructs itself with chain-derived data.
|
||||
let domain_errors = [
|
||||
AuditError::ChainViolation { seq: 7 },
|
||||
AuditError::HashMismatch { seq: 42 },
|
||||
AuditError::UnknownAction,
|
||||
];
|
||||
|
||||
for err in &domain_errors {
|
||||
let text = err.to_string();
|
||||
|
||||
// No form of the community id may appear.
|
||||
assert!(
|
||||
!text.contains(&community_str) && !text.contains(&community_simple),
|
||||
"audit error text leaked a community_id: {text:?}"
|
||||
);
|
||||
|
||||
// No Postgres constraint/PK names that would reveal schema shape or
|
||||
// the existence of a cross-community key.
|
||||
for needle in [
|
||||
"community_id",
|
||||
"audit_log_pkey",
|
||||
"constraint",
|
||||
"communities",
|
||||
] {
|
||||
assert!(
|
||||
!text.to_ascii_lowercase().contains(needle),
|
||||
"audit error text leaked a constraint/identifier '{needle}': {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The two chain-integrity variants must still carry their per-community
|
||||
// `seq` (the diagnostic is useless without it) — proves the assertion
|
||||
// above isn't vacuously passing on empty strings.
|
||||
assert!(AuditError::ChainViolation { seq: 7 }
|
||||
.to_string()
|
||||
.contains('7'));
|
||||
assert!(AuditError::HashMismatch { seq: 42 }
|
||||
.to_string()
|
||||
.contains("42"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use chrono::{DateTime, SubsecRound, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::entry::AuditEntry;
|
||||
use crate::error::AuditError;
|
||||
|
||||
/// The 32-byte sentinel hashed in place of `prev_hash` for a community's first
|
||||
/// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes.
|
||||
pub const GENESIS_HASH: [u8; 32] = [0u8; 32];
|
||||
|
||||
/// Reduce a timestamp to the precision the audit store round-trips.
|
||||
///
|
||||
/// `audit_log.created_at` is `TIMESTAMPTZ`, which Postgres keeps at microsecond
|
||||
/// resolution. [`compute_hash`] covers `created_at.to_rfc3339()`, and that
|
||||
/// string's sub-second digit count follows the value (chrono emits 0, 3, 6 or 9
|
||||
/// digits), so a timestamp carrying nanoseconds hashes to a digest that can
|
||||
/// never be recomputed from the stored row — the entry is written with one
|
||||
/// preimage and verified against another.
|
||||
///
|
||||
/// Every `created_at` must therefore pass through here *before* it is hashed
|
||||
/// and stored, so the in-memory entry and the row are byte-identical.
|
||||
pub fn to_storage_precision(created_at: DateTime<Utc>) -> DateTime<Utc> {
|
||||
created_at.trunc_subsecs(6)
|
||||
}
|
||||
|
||||
/// SHA-256 over the entry's identity, chain, and context fields.
|
||||
///
|
||||
/// Field order is fixed — changing it invalidates all existing chains. The
|
||||
/// `community_id` is hashed first so chain identity carries the tenant: an entry
|
||||
/// cannot be lifted out of one community's chain and re-verified inside another.
|
||||
///
|
||||
/// `created_at` is normalized through [`to_storage_precision`] here rather than
|
||||
/// hashed as given. Write paths truncate before storing so the row matches the
|
||||
/// in-memory entry, but normalizing again at the single point that consumes the
|
||||
/// value means no future caller can reintroduce the write/read preimage split
|
||||
/// by forgetting to. Values already at storage precision are unaffected —
|
||||
/// truncation is idempotent — so this does not change any digest.
|
||||
///
|
||||
/// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is
|
||||
/// stable across machines and Rust versions. A serialization failure is a hard
|
||||
/// error, never silently hashed as empty.
|
||||
pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> {
|
||||
let mut hasher = Sha256::new();
|
||||
// Tenant binding: community_id leads the hash.
|
||||
hasher.update(entry.community_id.as_bytes());
|
||||
hasher.update(entry.seq.to_be_bytes());
|
||||
hasher.update(
|
||||
to_storage_precision(entry.created_at)
|
||||
.to_rfc3339()
|
||||
.as_bytes(),
|
||||
);
|
||||
hasher.update(entry.action.as_str().as_bytes());
|
||||
match &entry.actor_pubkey {
|
||||
Some(pk) => {
|
||||
hasher.update([1u8]); // presence tag — distinguishes Some(empty) from None
|
||||
hasher.update(pk);
|
||||
}
|
||||
None => hasher.update([0u8]),
|
||||
}
|
||||
match &entry.object_id {
|
||||
Some(id) => {
|
||||
hasher.update([1u8]);
|
||||
hasher.update(id.as_bytes());
|
||||
}
|
||||
None => hasher.update([0u8]),
|
||||
}
|
||||
hasher.update(canonical_json(&entry.detail)?.as_bytes());
|
||||
match &entry.prev_hash {
|
||||
Some(h) => hasher.update(h),
|
||||
None => hasher.update(GENESIS_HASH),
|
||||
}
|
||||
Ok(hasher.finalize().into())
|
||||
}
|
||||
|
||||
/// Serialize a JSON value with sorted object keys for deterministic output.
|
||||
///
|
||||
/// Propagates any scalar serialization error rather than substituting a
|
||||
/// placeholder — a hash must never silently stand in an empty value for a real
|
||||
/// payload.
|
||||
fn canonical_json(value: &serde_json::Value) -> Result<String, serde_json::Error> {
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sorted: BTreeMap<&str, &Value> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||||
let mut out = String::from("{");
|
||||
let mut first = true;
|
||||
for (k, v) in &sorted {
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
first = false;
|
||||
out.push_str(&serde_json::to_string(k)?);
|
||||
out.push(':');
|
||||
out.push_str(&canonical_json(v)?);
|
||||
}
|
||||
out.push('}');
|
||||
Ok(out)
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut out = String::from("[");
|
||||
let mut first = true;
|
||||
for v in arr {
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
first = false;
|
||||
out.push_str(&canonical_json(v)?);
|
||||
}
|
||||
out.push(']');
|
||||
Ok(out)
|
||||
}
|
||||
other => serde_json::to_string(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{action::AuditAction, entry::AuditEntry};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample_entry() -> AuditEntry {
|
||||
AuditEntry {
|
||||
community_id: Uuid::from_u128(1),
|
||||
seq: 1,
|
||||
hash: Vec::new(),
|
||||
prev_hash: None,
|
||||
action: AuditAction::EventCreated,
|
||||
actor_pubkey: Some(vec![0xab; 32]),
|
||||
object_id: Some("abc123".into()),
|
||||
detail: serde_json::Value::Null,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
}
|
||||
}
|
||||
|
||||
/// A wall-clock instant carrying sub-microsecond digits, like `Utc::now()`
|
||||
/// returns on Linux (`clock_gettime`, nanosecond resolution).
|
||||
fn nanosecond_instant() -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::from_timestamp_nanos(1_700_000_000_123_456_789)
|
||||
}
|
||||
|
||||
/// What Postgres hands back for a `TIMESTAMPTZ`: microsecond resolution.
|
||||
fn after_database_round_trip(ts: chrono::DateTime<Utc>) -> chrono::DateTime<Utc> {
|
||||
ts.trunc_subsecs(6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic() {
|
||||
let entry = sample_entry();
|
||||
assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap());
|
||||
assert_eq!(compute_hash(&entry).unwrap().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_precision_drops_sub_microsecond_digits() {
|
||||
let stored = to_storage_precision(nanosecond_instant());
|
||||
assert_eq!(stored.timestamp_subsec_nanos(), 123_456_000);
|
||||
// Idempotent, so a stored value re-read from Postgres is unchanged.
|
||||
assert_eq!(stored, after_database_round_trip(stored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_sub_second_width_follows_the_value() {
|
||||
// The underlying trap, pinned on the preimage rather than the digest:
|
||||
// chrono emits 0/3/6/9 fractional digits depending on the value, so a
|
||||
// nanosecond timestamp and its microsecond truncation are *different
|
||||
// strings*. Hashing the untruncated value therefore produces a digest
|
||||
// that cannot be recomputed from the stored row — which is what made
|
||||
// every entry fail `verify_chain` with `HashMismatch`.
|
||||
let ns = nanosecond_instant();
|
||||
assert_eq!(ns.to_rfc3339(), "2023-11-14T22:13:20.123456789+00:00");
|
||||
assert_eq!(
|
||||
after_database_round_trip(ns).to_rfc3339(),
|
||||
"2023-11-14T22:13:20.123456+00:00"
|
||||
);
|
||||
assert_ne!(ns.to_rfc3339(), after_database_round_trip(ns).to_rfc3339());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_hash_normalizes_sub_microsecond_timestamps() {
|
||||
// The enforcement point: even handed an untruncated `created_at`,
|
||||
// `compute_hash` digests the storage-precision value, so a write path
|
||||
// that forgot to truncate cannot split the write/read preimage.
|
||||
let ns = nanosecond_instant();
|
||||
let mut written = sample_entry();
|
||||
written.created_at = ns;
|
||||
let mut read_back = sample_entry();
|
||||
read_back.created_at = after_database_round_trip(ns);
|
||||
|
||||
assert_eq!(
|
||||
compute_hash(&written).unwrap(),
|
||||
compute_hash(&read_back).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_precision_timestamps_survive_a_database_round_trip() {
|
||||
// The invariant the write path must hold: hash what will be stored, so
|
||||
// recomputing from the row reproduces the digest.
|
||||
let mut written = sample_entry();
|
||||
written.created_at = to_storage_precision(nanosecond_instant());
|
||||
let mut read_back = written.clone();
|
||||
read_back.created_at = after_database_round_trip(read_back.created_at);
|
||||
|
||||
assert_eq!(
|
||||
compute_hash(&written).unwrap(),
|
||||
compute_hash(&read_back).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn community_id_is_part_of_identity() {
|
||||
// The whole point: the same logical entry in two communities hashes
|
||||
// differently, so a row can't be replayed across chains.
|
||||
let a = sample_entry();
|
||||
let mut b = a.clone();
|
||||
b.community_id = Uuid::from_u128(2);
|
||||
assert_ne!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_to_each_field() {
|
||||
let base = sample_entry();
|
||||
let h0 = compute_hash(&base).unwrap();
|
||||
|
||||
let mut e = base.clone();
|
||||
e.seq = 2;
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.action = AuditAction::EventDeleted;
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.actor_pubkey = Some(vec![0xcd; 32]);
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.object_id = Some("different".into());
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.detail = serde_json::json!({"key": "value"});
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
|
||||
let mut e = base.clone();
|
||||
e.prev_hash = Some(vec![0xff; 32]);
|
||||
assert_ne!(h0, compute_hash(&e).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_tag_distinguishes_none_from_empty() {
|
||||
// Some(empty) must not collide with None — the presence tag prevents it.
|
||||
let mut none = sample_entry();
|
||||
none.actor_pubkey = None;
|
||||
let mut empty = sample_entry();
|
||||
empty.actor_pubkey = Some(Vec::new());
|
||||
assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_json_key_order_is_stable() {
|
||||
let a = serde_json::json!({"z": 1, "a": 2, "m": 3});
|
||||
let b = serde_json::json!({"a": 2, "m": 3, "z": 1});
|
||||
assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! Tamper-evident, **per-community** hash-chain audit log.
|
||||
//!
|
||||
//! Each community owns an independent chain: rows are keyed `(community_id, seq)`,
|
||||
//! `seq` is monotonic *within a community*, and each entry chains to the previous
|
||||
//! entry *of the same community* via SHA-256. The `community_id` is folded into the
|
||||
//! hash, so a row lifted out of one community's chain can never verify inside
|
||||
//! another's — chain identity carries the tenant. This is the audit half of the
|
||||
//! non-interference floor (`auditHeads[c]` in `MultiTenantRelay.tla`): an audit
|
||||
//! observation reveals only its own community's head.
|
||||
//!
|
||||
//! Writes for a given community are serialized by a **per-community** Postgres
|
||||
//! advisory lock, so the chain stays consistent across relay processes without one
|
||||
//! global lock serializing (and timing-coupling) every tenant.
|
||||
//!
|
||||
//! The `audit_log` table is owned by the consolidated `0001` migration — this crate
|
||||
//! is pure chain logic and ships no DDL.
|
||||
|
||||
/// Audit action types recorded in the log.
|
||||
pub mod action;
|
||||
/// Audit log entry types (stored and input).
|
||||
pub mod entry;
|
||||
/// Error types for audit operations.
|
||||
pub mod error;
|
||||
/// SHA-256 hash computation for audit entries.
|
||||
pub mod hash;
|
||||
/// Audit log service — append and verify entries.
|
||||
pub mod service;
|
||||
|
||||
pub use action::AuditAction;
|
||||
pub use entry::{AuditEntry, NewAuditEntry};
|
||||
pub use error::AuditError;
|
||||
pub use hash::{compute_hash, GENESIS_HASH};
|
||||
pub use service::AuditService;
|
||||
@@ -0,0 +1,527 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::FutureExt as _;
|
||||
use sqlx::{Acquire, PgPool, Row};
|
||||
use tracing::{debug, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_core::CommunityId;
|
||||
|
||||
use crate::{
|
||||
action::AuditAction,
|
||||
entry::{AuditEntry, NewAuditEntry},
|
||||
error::AuditError,
|
||||
hash::{compute_hash, to_storage_precision},
|
||||
};
|
||||
|
||||
/// The `created_at` stamped on a new entry.
|
||||
///
|
||||
/// Reduced to the precision Postgres round-trips before it is hashed — see
|
||||
/// [`to_storage_precision`]. Split out from [`AuditService::log_inner`] so the
|
||||
/// invariant is testable without a database.
|
||||
fn log_timestamp() -> DateTime<Utc> {
|
||||
to_storage_precision(Utc::now())
|
||||
}
|
||||
|
||||
/// Per-community advisory lock key. Derived in Postgres from the community UUID
|
||||
/// so two communities never serialize each other's audit writes (which would be
|
||||
/// both a throughput bottleneck and a cross-tenant timing oracle). The lock is
|
||||
/// taken with `pg_advisory_lock(hashtextextended(...))` — see [`AuditService::log`].
|
||||
const AUDIT_LOCK_NAMESPACE: &str = "buzz_audit:";
|
||||
|
||||
/// Append-only, per-community hash-chain audit log backed by Postgres.
|
||||
///
|
||||
/// Each community has an independent chain keyed `(community_id, seq)`. Writes
|
||||
/// for one community are serialized by a per-community advisory lock so the chain
|
||||
/// stays consistent across relay processes; different communities proceed in
|
||||
/// parallel.
|
||||
pub struct AuditService {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl AuditService {
|
||||
/// Creates a new `AuditService` using the given connection pool.
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Append a new entry to the calling community's chain.
|
||||
///
|
||||
/// Serialized per-community via `pg_advisory_lock`. Postgres advisory locks
|
||||
/// are session-scoped, so we acquire before the transaction and release
|
||||
/// after commit (or on any error path).
|
||||
#[instrument(skip(self, entry), fields(action = %entry.action))]
|
||||
pub async fn log(&self, entry: NewAuditEntry) -> Result<AuditEntry, AuditError> {
|
||||
let mut conn = self.pool.acquire().await?;
|
||||
|
||||
// Per-community advisory lock: hash the namespaced community id to an
|
||||
// i64 lock key inside Postgres. Communities lock independently.
|
||||
let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{}", entry.community_id);
|
||||
sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))")
|
||||
.bind(&lock_key)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
// Run the chain append and release the lock regardless of outcome.
|
||||
// catch_unwind so a panic still releases the lock before the connection
|
||||
// returns to the pool.
|
||||
let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry))
|
||||
.catch_unwind()
|
||||
.await;
|
||||
|
||||
let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))")
|
||||
.bind(&lock_key)
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(inner_result) => inner_result,
|
||||
Err(panic_payload) => std::panic::resume_unwind(panic_payload),
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_inner(
|
||||
&self,
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
entry: NewAuditEntry,
|
||||
) -> 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(community_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (prev_seq, prev_hash): (i64, Option<Vec<u8>>) = match head {
|
||||
Some(row) => (
|
||||
row.get::<i64, _>("seq"),
|
||||
Some(row.get::<Vec<u8>, _>("hash")),
|
||||
),
|
||||
None => (0, None), // community's first entry
|
||||
};
|
||||
let seq = prev_seq + 1;
|
||||
|
||||
let created_at: DateTime<Utc> = log_timestamp();
|
||||
|
||||
let mut audit_entry = AuditEntry {
|
||||
community_id,
|
||||
seq,
|
||||
hash: Vec::new(),
|
||||
prev_hash,
|
||||
action: entry.action,
|
||||
actor_pubkey: entry.actor_pubkey,
|
||||
object_id: entry.object_id,
|
||||
detail: entry.detail,
|
||||
created_at,
|
||||
};
|
||||
|
||||
audit_entry.hash = compute_hash(&audit_entry)?.to_vec();
|
||||
|
||||
debug!(seq, "writing audit entry");
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_log
|
||||
(community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
)
|
||||
.bind(audit_entry.community_id)
|
||||
.bind(audit_entry.seq)
|
||||
.bind(&audit_entry.hash)
|
||||
.bind(audit_entry.prev_hash.as_deref())
|
||||
.bind(audit_entry.action.as_str())
|
||||
.bind(audit_entry.actor_pubkey.as_deref())
|
||||
.bind(audit_entry.object_id.as_deref())
|
||||
.bind(&audit_entry.detail)
|
||||
.bind(audit_entry.created_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(audit_entry)
|
||||
}
|
||||
|
||||
/// Verify the hash chain for one community over `[from_seq, to_seq]`.
|
||||
///
|
||||
/// Reads exactly that community's chain — it can never observe another
|
||||
/// community's entries or head. Returns `Ok(false)` if the range is empty,
|
||||
/// `Ok(true)` if the segment is internally consistent.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn verify_chain(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
from_seq: i64,
|
||||
to_seq: i64,
|
||||
) -> Result<bool, AuditError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT community_id, seq, hash, prev_hash, action, actor_pubkey,
|
||||
object_id, detail, created_at
|
||||
FROM audit_log
|
||||
WHERE community_id = $1 AND seq BETWEEN $2 AND $3
|
||||
ORDER BY seq ASC
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(from_seq)
|
||||
.bind(to_seq)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut expected_prev: Option<Vec<u8>> = None;
|
||||
|
||||
for row in &rows {
|
||||
let entry = row_to_audit_entry(row)?;
|
||||
|
||||
if let Some(ref expected) = expected_prev {
|
||||
// The previous entry's hash must equal this entry's prev_hash.
|
||||
if entry.prev_hash.as_deref() != Some(expected.as_slice()) {
|
||||
return Err(AuditError::ChainViolation { seq: entry.seq });
|
||||
}
|
||||
}
|
||||
|
||||
let computed = compute_hash(&entry)?;
|
||||
if computed.as_slice() != entry.hash.as_slice() {
|
||||
return Err(AuditError::HashMismatch { seq: entry.seq });
|
||||
}
|
||||
|
||||
expected_prev = Some(entry.hash);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Returns up to `limit` entries from one community's chain starting at
|
||||
/// `from_seq`, ordered by sequence number. Scoped to `community` — never
|
||||
/// returns another community's rows.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_entries(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
from_seq: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<AuditEntry>, AuditError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT community_id, seq, hash, prev_hash, action, actor_pubkey,
|
||||
object_id, detail, created_at
|
||||
FROM audit_log
|
||||
WHERE community_id = $1 AND seq >= $2
|
||||
ORDER BY seq ASC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(from_seq)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
rows.iter().map(row_to_audit_entry).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result<AuditEntry, AuditError> {
|
||||
let action_str: String = row.get("action");
|
||||
let action: AuditAction = action_str.parse().map_err(|_| {
|
||||
warn!("unknown action in audit log");
|
||||
AuditError::UnknownAction
|
||||
})?;
|
||||
|
||||
Ok(AuditEntry {
|
||||
community_id: row.get::<Uuid, _>("community_id"),
|
||||
seq: row.get("seq"),
|
||||
hash: row.get("hash"),
|
||||
prev_hash: row.get("prev_hash"),
|
||||
action,
|
||||
actor_pubkey: row.get("actor_pubkey"),
|
||||
object_id: row.get("object_id"),
|
||||
detail: row.get("detail"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::AuditAction;
|
||||
use crate::entry::NewAuditEntry;
|
||||
use chrono::SubsecRound;
|
||||
use std::sync::OnceLock;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
// The per-community advisory lock means different communities don't contend,
|
||||
// but tests share one table; serialize them so seq assertions are stable.
|
||||
static DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
fn db_lock() -> &'static Mutex<()> {
|
||||
DB_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into());
|
||||
PgPool::connect(&url).await.ok()
|
||||
}
|
||||
|
||||
/// Runs without Postgres, so a regression here is caught by `just
|
||||
/// test-unit` rather than only by the `#[ignore]` chain tests below.
|
||||
#[test]
|
||||
fn log_timestamp_carries_no_sub_microsecond_digits() {
|
||||
let ts = log_timestamp();
|
||||
assert_eq!(
|
||||
ts,
|
||||
ts.trunc_subsecs(6),
|
||||
"created_at is hashed and then stored in a TIMESTAMPTZ column; \
|
||||
sub-microsecond digits make every entry fail verify_chain"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `community_id` known to exist in `communities` (FK target). Inserts a
|
||||
/// throwaway community row with a unique host and returns its id.
|
||||
async fn make_community(pool: &PgPool) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("test-{id}.example");
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(host)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert test community");
|
||||
id
|
||||
}
|
||||
|
||||
fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry {
|
||||
NewAuditEntry {
|
||||
community_id: CommunityId::from_uuid(community_id),
|
||||
action,
|
||||
actor_pubkey: Some(vec![0xab; 32]),
|
||||
object_id: Some(format!("obj_{}", Uuid::new_v4())),
|
||||
detail: serde_json::json!({"test": true}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn community_chain_starts_at_seq_1_with_null_prev() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
let e = svc
|
||||
.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(e.seq, 1, "first entry in a community starts at seq 1");
|
||||
assert!(e.prev_hash.is_none(), "genesis entry has NULL prev_hash");
|
||||
assert_eq!(e.hash.len(), 32);
|
||||
assert_eq!(e.community_id, c);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn chain_links_within_one_community() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
let e1 = svc
|
||||
.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e2 = svc
|
||||
.log(new_entry(c, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e3 = svc
|
||||
.log(new_entry(c, AuditAction::MemberAdded))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(e1.seq, 1);
|
||||
assert_eq!(e2.seq, 2);
|
||||
assert_eq!(e3.seq, 3);
|
||||
assert!(e1.prev_hash.is_none());
|
||||
assert_eq!(e2.prev_hash.as_deref(), Some(e1.hash.as_slice()));
|
||||
assert_eq!(e3.prev_hash.as_deref(), Some(e2.hash.as_slice()));
|
||||
assert!(svc
|
||||
.verify_chain(CommunityId::from_uuid(c), 1, 3)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// THE isolation property: two communities keep independent chains. Each
|
||||
/// starts at seq 1; interleaving writes does not link them; verifying one
|
||||
/// never traverses the other.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn chains_are_independent_per_community() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let a = make_community(&pool).await;
|
||||
let b = make_community(&pool).await;
|
||||
|
||||
// Interleave A and B writes.
|
||||
let a1 = svc
|
||||
.log(new_entry(a, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let b1 = svc
|
||||
.log(new_entry(b, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let a2 = svc
|
||||
.log(new_entry(a, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let b2 = svc
|
||||
.log(new_entry(b, AuditAction::ChannelCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Each community's seq is independent and starts at 1.
|
||||
assert_eq!((a1.seq, a2.seq), (1, 2));
|
||||
assert_eq!((b1.seq, b2.seq), (1, 2));
|
||||
|
||||
// A's chain links only within A; B's only within B. A2 must NOT chain to
|
||||
// B1 even though B1 was written between A1 and A2.
|
||||
assert_eq!(a2.prev_hash.as_deref(), Some(a1.hash.as_slice()));
|
||||
assert_eq!(b2.prev_hash.as_deref(), Some(b1.hash.as_slice()));
|
||||
assert_ne!(a2.prev_hash, b1.prev_hash);
|
||||
|
||||
// Verifying A's chain traverses only A; same for B.
|
||||
assert!(svc
|
||||
.verify_chain(CommunityId::from_uuid(a), 1, 2)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(svc
|
||||
.verify_chain(CommunityId::from_uuid(b), 1, 2)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// get_entries scoped to A returns only A's rows.
|
||||
let a_rows = svc
|
||||
.get_entries(CommunityId::from_uuid(a), 1, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
a_rows.iter().all(|e| e.community_id == a),
|
||||
"A read leaked another community"
|
||||
);
|
||||
assert_eq!(a_rows.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn verify_detects_tampering_within_a_community() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
|
||||
svc.log(new_entry(c, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
let e2 = svc
|
||||
.log(new_entry(c, AuditAction::EventDeleted))
|
||||
.await
|
||||
.unwrap();
|
||||
svc.log(new_entry(c, AuditAction::ChannelDeleted))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Tamper with e2's stored actor_pubkey.
|
||||
let tampered: Vec<u8> = vec![0xff; 32];
|
||||
sqlx::query("UPDATE audit_log SET actor_pubkey = $1 WHERE community_id = $2 AND seq = $3")
|
||||
.bind(tampered)
|
||||
.bind(c)
|
||||
.bind(e2.seq)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let r = svc.verify_chain(CommunityId::from_uuid(c), 1, 3).await;
|
||||
assert!(matches!(r, Err(AuditError::HashMismatch { seq }) if seq == e2.seq));
|
||||
}
|
||||
|
||||
/// A row forged with another community's id cannot pass verification against
|
||||
/// the chain it was stamped for, because community_id is hashed in. (Models
|
||||
/// "a row can't be replayed across chains and still verify".)
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn cross_community_row_does_not_verify() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let a = make_community(&pool).await;
|
||||
let b = make_community(&pool).await;
|
||||
|
||||
let a1 = svc
|
||||
.log(new_entry(a, AuditAction::EventCreated))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Forge: copy A's seq-1 row's hash into B's chain at seq 1.
|
||||
sqlx::query(
|
||||
"INSERT INTO audit_log (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at)
|
||||
VALUES ($1, 1, $2, NULL, $3, $4, $5, $6, NOW())",
|
||||
)
|
||||
.bind(b)
|
||||
.bind(&a1.hash) // A's hash, which was computed over community_id = A
|
||||
.bind(a1.action.as_str())
|
||||
.bind(a1.actor_pubkey.as_deref())
|
||||
.bind(a1.object_id.as_deref())
|
||||
.bind(&a1.detail)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verifying B's chain recomputes the hash with community_id = B, which
|
||||
// won't match A's stored hash → HashMismatch. The forge is rejected.
|
||||
let r = svc.verify_chain(CommunityId::from_uuid(b), 1, 1).await;
|
||||
assert!(matches!(r, Err(AuditError::HashMismatch { seq: 1 })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn verify_empty_range_is_false() {
|
||||
let _g = db_lock().lock().await;
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let c = make_community(&pool).await;
|
||||
// No entries for this fresh community.
|
||||
assert!(!svc
|
||||
.verify_chain(CommunityId::from_uuid(c), 1, 100)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user