mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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>
109 lines
4.3 KiB
Rust
109 lines
4.3 KiB
Rust
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"));
|
|
}
|
|
}
|