mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(conformance): audit_log isolation — doc-only row + integrated relay test + error sanitization
The audit log has no client-reachable wire surface: there is no /audit
route in the relay, and AuditError is never relayed to a client. A pure
black-box A≡B conformance row (the shape every other row in
conformance_multitenant.rs uses) is therefore impossible, and reaching
behind the wire into Postgres from that file would break its black-box
contract. So the obligation is proven across three honest homes:
1. Doc-only conformance row (conformance_multitenant.rs): cites the
no-wire-surface fact — a strictly stronger isolation claim than "the
oracle is denied" — plus the per-community-chain substrate and the two
executable proofs below.
2. Integrated relay test (buzz-relay handlers::event): drives
dispatch_persistent_event under two tenants against a shared Postgres
and asserts each community's audit chain contains only its own
object_id and verifies independently. Proves the
host→TenantContext→chain wiring keeps tenants isolated end-to-end.
No WS-AUTH in the loop, so it is not blocked on NIP-42.
3. Error-sanitization unit test (buzz-audit error): asserts no AuditError
variant's rendered text embeds a community_id, constraint name, or
cross-community object id, with a non-vacuous check that per-community
seq still appears.
Both runnable pieces mutate-bitten: a stale-tenant scoping bug reds the
isolation assertion ("B's event id appeared in A's chain"); leaking a
constraint name into an #[error] string reds the sanitization assertion.
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
@@ -39,3 +39,70 @@ pub enum AuditError {
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1447,147 @@ mod tests {
|
||||
"audit must NOT record the relay signer as the actor"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integrated isolation: a community resolved from the request's
|
||||
/// `TenantContext` at relay ingest lands in *that* community's audit
|
||||
/// chain and nothing else. This is the conformance `audit_log` row's
|
||||
/// "one chain per community" obligation proven through the *relay* path
|
||||
/// (`dispatch_persistent_event`), not just the direct `AuditService::log`
|
||||
/// call that `buzz_audit::service::tests::chains_are_independent_per_community`
|
||||
/// covers — it pins that the host→`TenantContext`→chain wiring keeps
|
||||
/// tenants isolated end-to-end. No WS-AUTH in the loop, so it is not
|
||||
/// blocked on the NIP-42 work: it drives the dispatch fn directly with
|
||||
/// two explicit tenants.
|
||||
#[tokio::test]
|
||||
async fn audit_chain_is_isolated_per_tenant_through_relay_ingest() {
|
||||
use buzz_audit::AuditService;
|
||||
use buzz_core::event::StoredEvent;
|
||||
use buzz_core::tenant::{CommunityId, TenantContext};
|
||||
|
||||
let Some((state, audit_shutdown, pool)) = super::fanout_access::audit_state().await
|
||||
else {
|
||||
eprintln!("skipping audit isolation test: Postgres/Redis unavailable");
|
||||
return;
|
||||
};
|
||||
|
||||
// Two communities on the same relay process / same Postgres.
|
||||
let mut tenants = Vec::new();
|
||||
for label in ["a", "b"] {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("audit-iso-{label}-{}.example", id.simple());
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(&host)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed community");
|
||||
tenants.push((
|
||||
id,
|
||||
TenantContext::resolved(CommunityId::from_uuid(id), host),
|
||||
));
|
||||
}
|
||||
let (a_id, tenant_a) = &tenants[0];
|
||||
let (b_id, tenant_b) = &tenants[1];
|
||||
|
||||
// Ingest one event under each tenant. Each event is signed by an
|
||||
// arbitrary actor; the audit community comes from the *tenant*, not
|
||||
// the event — that is the property under test. The two events carry
|
||||
// distinct content so they get distinct ids: that is what makes the
|
||||
// cross-leak assertions below non-trivial (each id must appear only
|
||||
// in its own community's chain).
|
||||
let actor = Keys::generate();
|
||||
let actor_hex = actor.public_key().to_hex();
|
||||
let ingest = |tenant: &TenantContext, content: &str| {
|
||||
let event = EventBuilder::new(Kind::from(KIND_PRESENCE_UPDATE as u16), content)
|
||||
.sign_with_keys(&actor)
|
||||
.expect("sign event");
|
||||
let object_id = event.id.to_hex();
|
||||
let stored = StoredEvent::new(event, None);
|
||||
(object_id, stored, tenant.clone())
|
||||
};
|
||||
let (a_object, a_stored, ta) = ingest(tenant_a, "online-a");
|
||||
let (b_object, b_stored, tb) = ingest(tenant_b, "online-b");
|
||||
assert_ne!(
|
||||
a_object, b_object,
|
||||
"test precondition: the two events must have distinct ids"
|
||||
);
|
||||
|
||||
super::super::dispatch_persistent_event(
|
||||
&ta,
|
||||
&state,
|
||||
&a_stored,
|
||||
KIND_PRESENCE_UPDATE,
|
||||
&actor_hex,
|
||||
)
|
||||
.await;
|
||||
super::super::dispatch_persistent_event(
|
||||
&tb,
|
||||
&state,
|
||||
&b_stored,
|
||||
KIND_PRESENCE_UPDATE,
|
||||
&actor_hex,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_shutdown
|
||||
.drain(std::time::Duration::from_secs(5))
|
||||
.await;
|
||||
|
||||
// Read each chain back through the operator-internal API.
|
||||
let svc = AuditService::new(pool.clone());
|
||||
let a_rows = svc
|
||||
.get_entries(CommunityId::from_uuid(*a_id), 1, 1000)
|
||||
.await
|
||||
.expect("read A chain");
|
||||
let b_rows = svc
|
||||
.get_entries(CommunityId::from_uuid(*b_id), 1, 1000)
|
||||
.await
|
||||
.expect("read B chain");
|
||||
|
||||
// A's chain contains A's event and never B's; reverse holds too.
|
||||
assert!(
|
||||
a_rows.iter().all(|e| e.community_id == *a_id),
|
||||
"A read leaked another community's rows"
|
||||
);
|
||||
assert!(
|
||||
a_rows
|
||||
.iter()
|
||||
.any(|e| e.object_id.as_deref() == Some(a_object.as_str())),
|
||||
"A's ingested event is missing from A's chain"
|
||||
);
|
||||
assert!(
|
||||
!a_rows
|
||||
.iter()
|
||||
.any(|e| e.object_id.as_deref() == Some(b_object.as_str())),
|
||||
"B's event id appeared in A's audit chain — tenant isolation broken"
|
||||
);
|
||||
assert!(
|
||||
b_rows.iter().all(|e| e.community_id == *b_id),
|
||||
"B read leaked another community's rows"
|
||||
);
|
||||
assert!(
|
||||
!b_rows
|
||||
.iter()
|
||||
.any(|e| e.object_id.as_deref() == Some(a_object.as_str())),
|
||||
"A's event id appeared in B's audit chain — tenant isolation broken"
|
||||
);
|
||||
|
||||
// Each chain verifies independently over its own range.
|
||||
let a_max = a_rows.iter().map(|e| e.seq).max().expect("A has entries");
|
||||
let b_max = b_rows.iter().map(|e| e.seq).max().expect("B has entries");
|
||||
assert!(
|
||||
svc.verify_chain(CommunityId::from_uuid(*a_id), 1, a_max)
|
||||
.await
|
||||
.expect("verify A"),
|
||||
"A's chain must verify independently"
|
||||
);
|
||||
assert!(
|
||||
svc.verify_chain(CommunityId::from_uuid(*b_id), 1, b_max)
|
||||
.await
|
||||
.expect("verify B"),
|
||||
"B's chain must verify independently"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod fanout_access {
|
||||
|
||||
@@ -457,19 +457,59 @@ mod mesh_agents_cli {
|
||||
// Audit log and observability (Dawn — buzz-audit)
|
||||
// ---------------------------------------------------------------------------
|
||||
mod audit_log {
|
||||
use super::*;
|
||||
|
||||
/// Obligation: audit reads verify exactly one community chain
|
||||
/// (`(community_id, seq)` / `(community_id, hash)`); error strings must not
|
||||
/// leak cross-community IDs, constraint names, or existence facts.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn audit_chain_is_single_community_and_errors_dont_leak() {
|
||||
pending_lane(
|
||||
"buzz-audit",
|
||||
"verify one chain per community; no cross-community id/constraint in error text",
|
||||
);
|
||||
}
|
||||
//! Obligation: audit reads verify exactly one community chain
|
||||
//! (`(community_id, seq)` / `(community_id, hash)`); error strings must not
|
||||
//! leak cross-community IDs, constraint names, or existence facts.
|
||||
//!
|
||||
//! **This row is doc-only — and that is the strongest statement in the file.**
|
||||
//!
|
||||
//! Every other row here proves a black-box property: the relay serves a wire
|
||||
//! response, and the test asserts that response denies a cross-community
|
||||
//! oracle. The audit log has no such response to assert against — it has **no
|
||||
//! client-reachable wire surface at all**. There is no `/audit` route in
|
||||
//! `crates/buzz-relay/src/router.rs` (the route list is `/`, `/info`,
|
||||
//! `/.well-known/nostr.json`, the health probes, `/events`, `/query`,
|
||||
//! `/count`, `/hooks`, the media and git sub-routers, and the audio WS — no
|
||||
//! audit endpoint). Audit is written as an ingest side-effect
|
||||
//! (`handlers/event.rs`, `dispatch_persistent_event`) and read only via
|
||||
//! `buzz_audit::AuditService::{verify_chain, get_entries}`, which are
|
||||
//! operator-internal (consumed by `buzz-admin`). `crates/buzz-audit/src/
|
||||
//! error.rs` states it directly: `AuditError` is "never relayed to a client
|
||||
//! on the wire," and "no variant embeds a `community_id`."
|
||||
//!
|
||||
//! So where other rows prove *the oracle is denied*, audit proves *the
|
||||
//! oracle's surface does not exist* — a strictly stronger isolation claim,
|
||||
//! and the honest way to state it is to cite the facts, not to invent a wire
|
||||
//! observation that the architecture does not offer. Reaching behind the
|
||||
//! wire into Postgres here would also break this file's black-box contract
|
||||
//! (its deps are `buzz-ws-client`/`reqwest`/`tokio-tungstenite`/`s3` — no
|
||||
//! `sqlx`, no `buzz-audit`), and a DB-direct read can never catch a
|
||||
//! wire-layer bug because it never traverses the wire read path.
|
||||
//!
|
||||
//! The two halves of the obligation are proven in their proper homes, where
|
||||
//! direct Postgres access is in-convention:
|
||||
//!
|
||||
//! 1. **One chain per community** —
|
||||
//! `buzz_audit::service::tests::chains_are_independent_per_community`
|
||||
//! (direct `AuditService::log`) proves interleaved A/B writes keep
|
||||
//! independent `(community_id, seq)` chains, each starting at seq 1 with
|
||||
//! its own `prev_hash`, and that `verify_chain`/`get_entries` scoped to
|
||||
//! one community never traverse another. The *integrated* path — that a
|
||||
//! community resolved from the request's `TenantContext` at relay ingest
|
||||
//! lands in the correct chain and stays isolated — is proven by
|
||||
//! `buzz_relay::handlers::event::tests::
|
||||
//! audit_chain_is_isolated_per_tenant_through_relay_ingest`, driving
|
||||
//! `dispatch_persistent_event` under two tenants against a shared
|
||||
//! Postgres (no WS-AUTH dependency).
|
||||
//! 2. **Errors don't leak** —
|
||||
//! `buzz_audit::error::tests::audit_error_text_carries_no_community_id_or_constraint`
|
||||
//! asserts no `AuditError` variant's rendered text embeds a
|
||||
//! `community_id`, constraint name, or cross-community object id.
|
||||
//!
|
||||
//! Substrate on PR head: `crates/buzz-audit/src/entry.rs` keys `AuditEntry`
|
||||
//! `(community_id, seq)` with per-community `prev_hash`; `NewAuditEntry.
|
||||
//! community_id` is typed `CommunityId` (server-resolved, never client
|
||||
//! input).
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user