mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
relay: add audit logging disable switch (#2134)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
parent
2b0f5e9f80
commit
bf5acabdde
@@ -270,6 +270,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
|
||||
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
|
||||
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
|
||||
| `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. |
|
||||
| `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. |
|
||||
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
|
||||
| `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start |
|
||||
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
|
||||
|
||||
@@ -420,24 +420,25 @@ pub async fn upload_blob(
|
||||
.increment(1);
|
||||
|
||||
// Audit via bounded channel — same pattern as event audit.
|
||||
let desc = descriptor.clone();
|
||||
if let Err(e) = state
|
||||
.audit_tx
|
||||
.send(NewAuditEntry {
|
||||
community_id: auth.tenant.community(),
|
||||
action: AuditAction::MediaUploaded,
|
||||
actor_pubkey: Some(auth.auth_event.pubkey.to_bytes().to_vec()),
|
||||
object_id: Some(desc.sha256.clone()),
|
||||
detail: serde_json::json!({
|
||||
"sha256": desc.sha256,
|
||||
"size": desc.size,
|
||||
"mime": desc.mime_type,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Media audit channel closed — entry lost: {e}");
|
||||
metrics::counter!("buzz_audit_send_errors_total").increment(1);
|
||||
if let Some(audit_tx) = &state.audit_tx {
|
||||
let desc = descriptor.clone();
|
||||
if let Err(e) = audit_tx
|
||||
.send(NewAuditEntry {
|
||||
community_id: auth.tenant.community(),
|
||||
action: AuditAction::MediaUploaded,
|
||||
actor_pubkey: Some(auth.auth_event.pubkey.to_bytes().to_vec()),
|
||||
object_id: Some(desc.sha256.clone()),
|
||||
detail: serde_json::json!({
|
||||
"sha256": desc.sha256,
|
||||
"size": desc.size,
|
||||
"mime": desc.mime_type,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Media audit channel closed — entry lost: {e}");
|
||||
metrics::counter!("buzz_audit_send_errors_total").increment(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(descriptor))
|
||||
|
||||
@@ -190,6 +190,11 @@ pub struct Config {
|
||||
/// serving media GET/HEAD. Default off for staged client rollout.
|
||||
pub require_media_get_auth: bool,
|
||||
|
||||
/// Whether tamper-evident event/media audit logging is enabled. Defaults to true.
|
||||
/// This does not control the separate `moderation_actions` audit trail.
|
||||
/// Set `BUZZ_AUDIT_ENABLED=false` for deployments that do not require it.
|
||||
pub audit_enabled: bool,
|
||||
|
||||
/// Optional override for ephemeral channel TTL (in seconds).
|
||||
/// When set, any channel created with a TTL tag will use this value instead
|
||||
/// of the client-provided one. Useful for testing ephemeral expiry quickly.
|
||||
@@ -341,9 +346,9 @@ fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
|
||||
fn parse_bool(name: &str, default: bool) -> Result<bool, ConfigError> {
|
||||
match std::env::var(name) {
|
||||
Err(std::env::VarError::NotPresent) => Ok(false),
|
||||
Err(std::env::VarError::NotPresent) => Ok(default),
|
||||
Err(error) => Err(ConfigError::InvalidValue(format!(
|
||||
"{name} must be valid UTF-8: {error}"
|
||||
))),
|
||||
@@ -357,6 +362,10 @@ fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_bool(name: &str) -> Result<bool, ConfigError> {
|
||||
parse_bool(name, false)
|
||||
}
|
||||
|
||||
fn ensure_git_repo_path(
|
||||
raw: impl Into<std::path::PathBuf>,
|
||||
) -> Result<std::path::PathBuf, ConfigError> {
|
||||
@@ -738,6 +747,7 @@ impl Config {
|
||||
let terms_markdown = read_policy_markdown("BUZZ_TERMS_OF_SERVICE_MARKDOWN")?;
|
||||
let privacy_markdown = read_policy_markdown("BUZZ_PRIVACY_POLICY_MARKDOWN")?;
|
||||
let age_attestation_required = parse_optional_bool("BUZZ_AGE_ATTESTATION_REQUIRED")?;
|
||||
let audit_enabled = parse_bool("BUZZ_AUDIT_ENABLED", true)?;
|
||||
let join_policy = if terms_markdown.is_none()
|
||||
&& privacy_markdown.is_none()
|
||||
&& !age_attestation_required
|
||||
@@ -849,6 +859,7 @@ impl Config {
|
||||
media_max_concurrent_uploads_per_pubkey,
|
||||
media_uploads_per_minute,
|
||||
require_media_get_auth,
|
||||
audit_enabled,
|
||||
ephemeral_ttl_override,
|
||||
git_repo_path,
|
||||
git_max_pack_bytes,
|
||||
@@ -953,6 +964,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_logging_defaults_on_and_accepts_explicit_off() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let previous = std::env::var_os("BUZZ_AUDIT_ENABLED");
|
||||
std::env::remove_var("BUZZ_AUDIT_ENABLED");
|
||||
assert!(parse_bool("BUZZ_AUDIT_ENABLED", true).unwrap());
|
||||
std::env::set_var("BUZZ_AUDIT_ENABLED", "false");
|
||||
assert!(!parse_bool("BUZZ_AUDIT_ENABLED", true).unwrap());
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var("BUZZ_AUDIT_ENABLED", value);
|
||||
} else {
|
||||
std::env::remove_var("BUZZ_AUDIT_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_logging_rejects_invalid_boolean() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let previous = std::env::var_os("BUZZ_AUDIT_ENABLED");
|
||||
std::env::set_var("BUZZ_AUDIT_ENABLED", "sometimes");
|
||||
let result = parse_bool("BUZZ_AUDIT_ENABLED", true);
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var("BUZZ_AUDIT_ENABLED", value);
|
||||
} else {
|
||||
std::env::remove_var("BUZZ_AUDIT_ENABLED");
|
||||
}
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ConfigError::InvalidValue(ref message))
|
||||
if message.contains("BUZZ_AUDIT_ENABLED")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_policy_age_attestation_rejects_invalid_boolean() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
|
||||
@@ -545,6 +545,9 @@ async fn enqueue_event_created_audit(
|
||||
actor_pubkey_hex: &str,
|
||||
event_id_hex: &str,
|
||||
) {
|
||||
let Some(audit_tx) = &state.audit_tx else {
|
||||
return;
|
||||
};
|
||||
// Audit via bounded channel (capacity 1000). Uses .send().await so entries
|
||||
// are never silently dropped — backpressure propagates to the event handler
|
||||
// if the queue is full. This is intentional: the audit advisory lock already
|
||||
@@ -568,7 +571,7 @@ async fn enqueue_event_created_audit(
|
||||
"channel_id": stored_event.channel_id,
|
||||
}),
|
||||
};
|
||||
if let Err(e) = state.audit_tx.send(audit_entry).await {
|
||||
if let Err(e) = audit_tx.send(audit_entry).await {
|
||||
error!(event_id = %event_id_hex, "Audit channel closed — entry lost: {e}");
|
||||
metrics::counter!("buzz_audit_send_errors_total").increment(1);
|
||||
}
|
||||
|
||||
@@ -128,12 +128,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
health_port = config.health_port,
|
||||
metrics_port = config.metrics_port,
|
||||
max_frame_bytes = config.max_frame_bytes,
|
||||
audit_enabled = config.audit_enabled,
|
||||
"Config loaded"
|
||||
);
|
||||
|
||||
let usage_interval_secs = usage_metrics_interval_secs();
|
||||
let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs);
|
||||
relay_metrics::install(config.metrics_port, usage_idle_timeout_secs);
|
||||
metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 });
|
||||
info!(
|
||||
port = config.metrics_port,
|
||||
idle_timeout_secs = usage_idle_timeout_secs,
|
||||
@@ -316,17 +318,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
Err(e) => error!("Failed to backfill d_tags: {e}"),
|
||||
}
|
||||
|
||||
let audit_pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.min_connections(1)
|
||||
.connect(&config.database_url)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?;
|
||||
let audit = AuditService::new(audit_pool);
|
||||
// Audit schema is provisioned by the sqlx migrations at startup (the
|
||||
// `audit_log` DDL is part of the migrated schema), so there is no runtime
|
||||
// schema-ensure step.
|
||||
info!("Audit service ready");
|
||||
let audit = if config.audit_enabled {
|
||||
let audit_pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.min_connections(1)
|
||||
.connect(&config.database_url)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?;
|
||||
info!("Audit service ready");
|
||||
Some(AuditService::new(audit_pool))
|
||||
} else {
|
||||
info!("Audit logging disabled by BUZZ_AUDIT_ENABLED");
|
||||
None
|
||||
};
|
||||
|
||||
let redis_pool = {
|
||||
let cfg = deadpool_redis::Config::from_url(&config.redis_url);
|
||||
|
||||
@@ -438,8 +438,8 @@ pub struct AppState {
|
||||
pub db: Db,
|
||||
/// Redis pool for readiness health checks.
|
||||
pub redis_pool: deadpool_redis::Pool,
|
||||
/// Audit event service.
|
||||
pub audit: Arc<AuditService>,
|
||||
/// Audit event service, absent when audit logging is disabled.
|
||||
pub audit: Option<Arc<AuditService>>,
|
||||
/// Pub/sub manager for broadcasting events to subscribers.
|
||||
pub pubsub: Arc<PubSubManager>,
|
||||
/// Authentication service.
|
||||
@@ -497,9 +497,8 @@ pub struct AppState {
|
||||
/// access check so open channels stay zero-cost. Invalidated on a flip.
|
||||
pub channel_visibility_cache: Arc<moka::sync::Cache<(CommunityId, Uuid), String>>,
|
||||
|
||||
/// Bounded channel for audit logging — backpressure instead of unbounded spawns.
|
||||
/// Uses .send().await (blocks caller if full) because audit entries must not be lost.
|
||||
pub audit_tx: mpsc::Sender<buzz_audit::NewAuditEntry>,
|
||||
/// Bounded channel for audit logging, absent when audit logging is disabled.
|
||||
pub audit_tx: Option<mpsc::Sender<buzz_audit::NewAuditEntry>>,
|
||||
/// Media storage client (S3/MinIO).
|
||||
pub media_storage: Arc<MediaStorage>,
|
||||
/// Git object-store backend (content-addressed packs/manifests plus
|
||||
@@ -571,7 +570,7 @@ impl AppState {
|
||||
config: Config,
|
||||
db: Db,
|
||||
redis_pool: deadpool_redis::Pool,
|
||||
audit: AuditService,
|
||||
audit: impl Into<Option<AuditService>>,
|
||||
pubsub: Arc<PubSubManager>,
|
||||
auth: AuthService,
|
||||
search: SearchService,
|
||||
@@ -583,12 +582,16 @@ impl AppState {
|
||||
let max_concurrent_handlers = config.max_concurrent_handlers;
|
||||
let search_arc = Arc::new(search);
|
||||
|
||||
let audit_arc = Arc::new(audit);
|
||||
let audit_arc = audit.into().map(Arc::new);
|
||||
let (audit_tx, mut audit_rx) = mpsc::channel::<buzz_audit::NewAuditEntry>(1000);
|
||||
let audit_for_worker = Arc::clone(&audit_arc);
|
||||
let audit_for_worker = audit_arc.clone();
|
||||
let audit_cancel = CancellationToken::new();
|
||||
let audit_cancel_worker = audit_cancel.clone();
|
||||
let audit_worker_handle = tokio::spawn(async move {
|
||||
let Some(audit_for_worker) = audit_for_worker else {
|
||||
audit_cancel_worker.cancelled().await;
|
||||
return;
|
||||
};
|
||||
// Normal operation: process entries as they arrive.
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -631,6 +634,7 @@ impl AppState {
|
||||
let nip98_replay: Arc<dyn Nip98ReplayGuard> =
|
||||
Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone()));
|
||||
let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone()));
|
||||
let audit_enabled = audit_arc.is_some();
|
||||
let state = Self {
|
||||
config: Arc::new(config),
|
||||
db,
|
||||
@@ -678,7 +682,7 @@ impl AppState {
|
||||
.support_invalidation_closures()
|
||||
.build(),
|
||||
),
|
||||
audit_tx,
|
||||
audit_tx: audit_enabled.then_some(audit_tx),
|
||||
media_storage: Arc::new(media_storage),
|
||||
git_store,
|
||||
audio_rooms: Arc::new(AudioRoomManager::new()),
|
||||
|
||||
Reference in New Issue
Block a user