Files
buzz/crates/buzz-audit/src/error.rs
Luke Tornquist d25384a4f3 buzz-db: admit every fenced-table writer in app code (#8219)
## Summary

This makes every serving writer of a community-write-fenced table take
community admission in application code. That's the "Dual enforcement"
step of the Supported Writer Contract in `ARCHITECTURE.md`. Admission
means the shared community deletion lock plus the active/not-deleted
check from `deletion::guard_community_write`. It's taken before the
transaction's first tenant write or row lock and held until commit.

The 32 `community_write_fence_*` triggers, the six fence SQL functions
and `validate_serving_catalog` are unchanged. They stay authoritative.
Removing them is a separate migration that will be checked table by
table.

## Changes

- **New admission entry point.** `begin_community_write_transaction`
(`runtime/mod.rs`) builds an `AdmittedTx` for non-event writers. It sits
next to the existing event-write entry points and shares their
implementation.
- **Writers moved onto it:**
- `api_tokens`, `archived_identities`, `channels`, `channel_members`
(including DM and admin paths), `community_bans`, `git_repo_names`
- `moderation_actions` and `moderation_reports`,
`personal_read_accounts` and `personal_read_frontiers`,
`pubkey_allowlist`
  - `push_leases`, `push_match_queue`, `push_wake_outbox`
- `relay_invites`, `relay_members` (including the new-community owner
bootstrap and the startup `pubkey_allowlist` backfill),
`join_policy_acceptances`
- `thread_metadata`, `users`, `workflows`, `workflow_runs`,
`workflow_approvals`, `scheduled_workflow_fires`
  - Personal-read helpers now require `&mut AdmittedTx`.
- **`buzz-audit`.** An append now passes admission first, then takes a
transaction-scoped audit-chain lock (`pg_advisory_xact_lock`). This
replaces the session lock and the `catch_unwind` unlock cleanup. The
relay audit worker retries a `lock_timeout` on either lock and keeps the
entry; an admission refusal is terminal, as the trigger's refusal was.
- **Fleet-wide statements.** These keep `community_write_allowed(...)`
in the same SQL statement, because there's no single community to admit
up front:
  - scheduled-fire prune
  - invite expiry
  - push matcher claim and exhausted-job cleanup
  - startup `d_tag` backfill
  - channel TTL cleanup
- **Relay-member removal.** `remove_relay_member` and
`remove_relay_member_if_role` run `delete_member_with_acceptances`
inside their admitted transaction. The helper no longer opens its own
transaction, so the member lock, the `join_policy_acceptances` delete
and the member delete all hold admission until commit. `delete_workflow`
and `delete_workflow_for_owner` are gone on `main`, and the coordinate
deletion path they used to back was already admitted.
- **Unchanged exceptions.** The deletion-executor generation exception
and the serving-lease path work as before.
- **Tables with no production writer.** `delivery_log` and
`subscriptions` are only written by a historical cutover script and a
test fixture. Their triggers stay.
- **No cross-community moves.** No production `community_id` move exists
on a fenced table. The only `SET community_id = NULL` is the
deletion-executor purge. So no dual-community lock ordering was needed.

## What got simpler

- Per-store ad-hoc writer helpers are gone:
`begin_event_write_transaction` and `acquire_event_write_connection` in
`channel.rs`, and `begin_operation_transaction` in `push.rs`.
- Audit append drops the session advisory unlock, the
`catch_unwind`/`resume_unwind` panic handling, `log_inner` and the
`futures-util` dependency.

## Notes for reviewers

- **New crate dependency.** `buzz-audit` now depends on `buzz-db` only
to call the free function `buzz_db::deletion::guard_community_write`.
`ARCHITECTURE.md` names this as the one sanctioned subsystem edge, and
its audit section is updated to match.
- **Hot paths.** `ensure_user` first checks whether the user exists and
returns without admission when it does (no write happens). Only a real
insert takes admission. `touch_api_token` has no production caller
today.
- **No-op writers stay read-only.** The startup allowlist backfill
returns early on plain reads when the legacy table is gone, it has no
rows for the community, or the community already has members, so a
quiescing deployment community still starts. It re-checks under
admission before inserting.
- **Owner mutations.** When admission refuses a transfer or a
provisioning and a deletion request exists, both report
`DeletionPending`, as before. For a transfer, the operator API keeps its
coded `deletion_lifecycle_conflict` 409. For provisioning, it keeps the
existing uncoded 409, `owner_conflict: community deletion is pending`.
- **Per-community worker loops.** `claim_due_push_wakes` and similar now
return `AccessDenied` for a community that goes quiescing between the
active-host scan and the claim. The worker logs it at `debug` and the
next scan skips the community.
- **Kick side effects.** `membership_removal_fence` admits before the
membership lock to keep the lock order of `add_member`. A community that
leaves `active` between the kick and the fence skips live subscription
eviction; the membership cache is already invalidated and fan-out
re-checks membership. This is documented on the function.

## Testing

- **New refusal tests.** 19 tests: the 18 in
`store::admission_postgres_tests`, plus
`audit_append_requires_application_admission_with_triggers_bypassed`.
Each runs with ordinary triggers disabled (`session_replication_role =
replica`), checks that a raw fenced insert would succeed, and asserts
that the writer is refused for both `quiescing` and `fenced`
communities. Active writes still succeed.
- **The tests catch a missing check.** Measured in review at
`6fc073c3f`: with the guard in `AdmittedTx::admit` bypassed, 25 tests
fail. That includes 16 of the 18 database admission tests. The audit
test stays green there, because audit calls `guard_community_write`
directly.
- **Affected PG suites, with triggers on:** 183/183 passed across 16
store suites, push 18/18 run isolated, audit 7/7. The existing artifact,
reaction and replaceable admission tests pass.
- **Review fixes, each checked to fail without its fix:**
- `allowlist_backfill_requires_application_admission` and
`owner_mutations_report_pending_deletion_when_admission_refuses` fail
with the old `relay_members.rs`.
- `empty_allowlist_backfill_skips_admission` fails (`AccessDenied …
write-fenced (quiescing)`) without the allowlist-rows early return.
-
`audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once`
holds the community deletion lock exclusive and fails without the
admission retry arm.
- The existing audit-chain retry test now looks for the
transaction-scoped lock and passes.
- The two owner-revoke mini-schema tests (`bridge.rs`, `ingest.rs`) now
include `communities` and `community_deletion_lock_key`, which admission
reads; both failed without that.
- **Static checks:** `cargo fmt --check` and workspace `cargo clippy
--all-targets -D warnings` pass.
- **Not run locally:** the `mobile-check` step of `just ci` (the build
machine couldn't reach pub.dev, and clean base fails the same way) and
the full `just test` lane. CI on this PR covers both.
`mesh_demo::demo_join_forwarded_arm_round_trips_echo` fails on the build
machine at base `394d4a434` as well; it uses no database.

Refs BUZZ-314.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-14 10:58:12 -04:00

124 lines
4.9 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),
/// Community write admission failed before the audit chain was locked.
#[error("audit write admission failed")]
Admission(#[from] buzz_db::DbError),
/// 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 },
AuditError::Admission(buzz_db::DbError::AccessDenied(format!(
"community {community} is write-fenced"
))),
];
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"));
}
}