mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Hash new audit entries from a TLV buffer built with one-byte field tags, big-endian u64 byte lengths, and raw values. Absent optional fields are omitted; present empty values retain their tag and zero length. This prevents bytes from moving between fields without restricting object IDs or inferring boundaries from field contents. Store an explicit `hash_version`. Migration 0048 labels existing rows as version 1, and new writers use version 2. Historical hashes are unchanged and mixed-version chains verify. The legacy encoding retains its original ambiguity; TLV protects new entries. Apply the migration before deploying the updated writers and verifiers. Tests cover both boundary collisions, absent versus empty fields, stable digests, unknown versions, mixed-version chains, and stored-row tampering. All 16 audit and 128 database unit tests passed. The six PostgreSQL audit tests passed against both migrated and fresh schemas, and a populated-row migration check preserved the historical hash. Full `just ci` passed. Related audit fixes: #4216 and #4249; issue #4173. Signed-off-by: Jordan Mecom <jm@squareup.com>
117 lines
4.6 KiB
Rust
117 lines
4.6 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,
|
|
|
|
/// The stored hash encoding version is not supported.
|
|
#[error("unsupported audit hash version {version}")]
|
|
UnsupportedHashVersion {
|
|
/// Unrecognised encoding version.
|
|
version: i16,
|
|
},
|
|
|
|
/// 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,
|
|
AuditError::UnsupportedHashVersion { version: 99 },
|
|
];
|
|
|
|
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"));
|
|
}
|
|
}
|