From 43f76199eff29aa47c77afc02872245b3bd3de1a Mon Sep 17 00:00:00 2001 From: judeedwards Date: Thu, 23 Jul 2026 14:13:44 +0000 Subject: [PATCH 1/4] relay: support host aliases so in-cluster clients can bind to a community Blox in-cluster clients can only reach the relay via a cluster-local Service DNS host, which has no communities.host row and 404s under row-zero binding. Host-header spoofing can't fix this: the mesh routes by Host (502 on mismatch) and WebSocket clients can't override it. Add community_host_aliases (unique on lower(host), guarded against collision with communities.host in both directions) and fall back to it in Db::lookup_community_by_host_or_alias after the primary host lookup misses. bind_community still binds the request's own host into TenantContext, so NIP-98/NIP-42 host checks are unaffected by aliasing. Add `buzz-admin community alias add|remove|list` to manage aliases for the tenant resolved from RELAY_URL. Co-Authored-By: Claude --- crates/buzz-admin/src/main.rs | 138 +++++++- crates/buzz-db/src/lib.rs | 319 +++++++++++++++++- crates/buzz-db/src/migration.rs | 17 +- crates/buzz-relay/src/tenant.rs | 29 +- .../tests/conformance_multitenant.rs | 214 ++++++++++++ migrations/0025_community_host_aliases.sql | 65 ++++ schema/schema.sql | 56 ++- 7 files changed, 823 insertions(+), 15 deletions(-) create mode 100644 migrations/0025_community_host_aliases.sql diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4..4f330229bc 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -24,8 +24,8 @@ use std::sync::Arc; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; -use buzz_core::tenant::{relay_url_authority, TenantContext}; -use buzz_db::{Db, DbConfig}; +use buzz_core::tenant::{normalize_host, relay_url_authority, TenantContext}; +use buzz_db::{AddHostAliasResult, Db, DbConfig}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; use nostr::{EventBuilder, Keys, Kind, Tag}; @@ -93,6 +93,43 @@ enum Command { #[arg(long)] relay_key: Option, }, + /// Manage this deployment's community (the one resolved from RELAY_URL). + Community { + #[command(subcommand)] + command: CommunityCommand, + }, +} + +#[derive(Subcommand)] +enum CommunityCommand { + /// Manage host aliases — additional hostnames that resolve to this + /// deployment's community without changing its canonical + /// `communities.host` (e.g. an in-cluster Service DNS name that has no + /// Host-header route to the community's external host). + Alias { + #[command(subcommand)] + command: AliasCommand, + }, +} + +#[derive(Subcommand)] +enum AliasCommand { + /// Add a host alias mapping to this deployment's community. + /// + /// Fails if the host is already some community's primary host, or + /// already aliased to a different community. + Add { + /// Additional hostname that should resolve to this community, e.g. + /// `sprout-relay.sprout.svc.cluster.local:3000`. + host: String, + }, + /// Remove a host alias from this deployment's community. + Remove { + /// Alias hostname to remove. + host: String, + }, + /// List host aliases for this deployment's community. + List, } #[derive(Subcommand)] @@ -152,6 +189,24 @@ async fn run(cli: Cli) -> Result { reconcile_channels(relay_key).await?; Ok(0) } + Command::Community { + command: + CommunityCommand::Alias { + command: AliasCommand::Add { host }, + }, + } => cmd_alias_add(host).await, + Command::Community { + command: + CommunityCommand::Alias { + command: AliasCommand::Remove { host }, + }, + } => cmd_alias_remove(host).await, + Command::Community { + command: + CommunityCommand::Alias { + command: AliasCommand::List, + }, + } => cmd_alias_list().await, } } @@ -286,6 +341,83 @@ async fn cmd_list_members() -> Result { Ok(0) } +async fn cmd_alias_add(host_arg: String) -> Result { + let host = normalize_host(&host_arg); + if host.is_empty() { + eprintln!("error: host must not be empty"); + return Ok(1); + } + + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + + match db + .add_community_host_alias(&host, tenant.community()) + .await? + { + AddHostAliasResult::Added(alias) => { + println!( + "added alias {} -> community {}", + alias.host, + tenant.community() + ); + Ok(0) + } + AddHostAliasResult::AlreadyAliased(existing) if existing == tenant.community() => { + println!("alias already exists: {host} (no change)"); + Ok(0) + } + AddHostAliasResult::AlreadyAliased(other) => { + eprintln!("error: host '{host}' is already aliased to a different community ({other})"); + Ok(2) + } + AddHostAliasResult::PrimaryHostCollision => { + eprintln!("error: host '{host}' is already a community's primary host"); + Ok(3) + } + } +} + +async fn cmd_alias_remove(host_arg: String) -> Result { + let host = normalize_host(&host_arg); + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + + if db + .remove_community_host_alias(&host, tenant.community()) + .await? + { + println!("removed alias {host}"); + Ok(0) + } else { + eprintln!("error: no alias '{host}' found for this community"); + Ok(2) + } +} + +async fn cmd_alias_list() -> Result { + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + let aliases = db.list_community_host_aliases(tenant.community()).await?; + + if aliases.is_empty() { + println!("(no host aliases)"); + return Ok(0); + } + + println!("{:<48} created_at", "host"); + println!("{}", "-".repeat(70)); + for alias in &aliases { + println!( + "{:<48} {}", + alias.host, + alias.created_at.format("%Y-%m-%dT%H:%M:%SZ") + ); + } + + Ok(0) +} + /// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`. fn validate_role(role: &str) -> std::result::Result<(), String> { match role { @@ -419,7 +551,7 @@ async fn connect_member_services() -> Result<(Db, Arc, Keys)> { async fn connect_db() -> Result { let db_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let db = Db::new(&DbConfig { database_url: db_url, ..DbConfig::default() diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index fdd72c3c32..0c2aed1db5 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -266,6 +266,31 @@ pub struct CommunityRecord { pub host: String, } +/// Host alias row returned by [`Db::list_community_host_aliases`] and +/// [`Db::add_community_host_alias`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityHostAlias { + /// Normalized alias hostname. + pub host: String, + /// Community the alias resolves to. + pub community_id: CommunityId, + /// When the alias was created. + pub created_at: DateTime, +} + +/// Result of attempting to add a host alias via [`Db::add_community_host_alias`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddHostAliasResult { + /// The alias was newly created. + Added(CommunityHostAlias), + /// The host is already an alias — for the same community (no-op) or a + /// different one (caller must decide whether that's an error). + AlreadyAliased(CommunityId), + /// The host is already some community's primary `communities.host`; an + /// alias would create an ambiguous fallback the resolver never reaches. + PrimaryHostCollision, +} + /// Community row returned by idempotent community ensure/create operations. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EnsuredCommunityRecord { @@ -710,6 +735,147 @@ impl Db { .transpose() } + /// Returns the community mapped to a normalized request host, checking + /// the primary `communities.host` map first and falling back to + /// `community_host_aliases`. + /// + /// This is the fallback-aware sibling of [`lookup_community_by_host`]; + /// [`crate::HostResolver`]'s `Db` impl (in `buzz-relay`) calls this one so + /// an in-cluster/alias host resolves the same community as the primary + /// host, without ever taking priority over it. Archived communities fail + /// closed via both paths, matching `lookup_community_by_host`. + /// + /// [`lookup_community_by_host`]: Self::lookup_community_by_host + pub async fn lookup_community_by_host_or_alias( + &self, + normalized_host: &str, + ) -> Result> { + if let Some(record) = self.lookup_community_by_host(normalized_host).await? { + return Ok(Some(record)); + } + + let row = sqlx::query( + r#" + SELECT c.id, c.host + FROM community_host_aliases a + JOIN communities c ON c.id = a.community_id + WHERE lower(a.host) = lower($1) + AND c.archived_at IS NULL + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Adds a host alias resolving to `community_id`. + /// + /// Checks upfront for a collision with any community's primary host so + /// the common case returns a typed [`AddHostAliasResult`] instead of a + /// raw constraint-violation error; migration 0025's trigger is the + /// belt-and-suspenders guard against the same race at the database level. + pub async fn add_community_host_alias( + &self, + normalized_host: &str, + community_id: CommunityId, + ) -> Result { + if self + .lookup_community_by_host_for_management(normalized_host) + .await? + .is_some() + { + return Ok(AddHostAliasResult::PrimaryHostCollision); + } + + let row = sqlx::query( + r#" + INSERT INTO community_host_aliases (host, community_id) + VALUES ($1, $2) + ON CONFLICT (lower(host)) DO NOTHING + RETURNING host, community_id, created_at + "#, + ) + .bind(normalized_host) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + if let Some(row) = row { + return Ok(AddHostAliasResult::Added(CommunityHostAlias { + host: row.try_get("host")?, + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + created_at: row.try_get("created_at")?, + })); + } + + let existing_community_id: Uuid = sqlx::query_scalar( + "SELECT community_id FROM community_host_aliases WHERE lower(host) = lower($1)", + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + Ok(AddHostAliasResult::AlreadyAliased(CommunityId::from_uuid( + existing_community_id, + ))) + } + + /// Removes a host alias, scoped to `community_id` so a caller can only + /// remove aliases belonging to the community it resolved to (mirrors the + /// scoping on [`remove_relay_member`](Self::remove_relay_member)). + /// + /// Returns `true` if a row was removed, `false` if no alias with that + /// host existed for this community. + pub async fn remove_community_host_alias( + &self, + normalized_host: &str, + community_id: CommunityId, + ) -> Result { + let result = sqlx::query( + "DELETE FROM community_host_aliases WHERE lower(host) = lower($1) AND community_id = $2", + ) + .bind(normalized_host) + .bind(community_id.as_uuid()) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Lists host aliases for `community_id`, oldest first. + pub async fn list_community_host_aliases( + &self, + community_id: CommunityId, + ) -> Result> { + let rows = sqlx::query( + r#" + SELECT host, community_id, created_at + FROM community_host_aliases + WHERE community_id = $1 + ORDER BY created_at ASC + "#, + ) + .bind(community_id.as_uuid()) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + Ok(CommunityHostAlias { + host: row.try_get("host")?, + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + created_at: row.try_get("created_at")?, + }) + }) + .collect() + } + /// Lists communities where `owner_pubkey` currently holds the `owner` role. /// /// This is an operator-plane helper, not a tenant-scoped data-plane read: @@ -3929,7 +4095,7 @@ mod tests { use sqlx::{Acquire, PgPool}; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_db() -> Db { let database_url = @@ -4719,6 +4885,157 @@ mod tests { assert_eq!(found.id, CommunityId::from_uuid(id)); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn alias_resolves_to_same_community_as_primary_host() { + let db = setup_db().await; + let host = format!("alias-primary-{}.example", Uuid::new_v4().simple()); + let alias_host = format!("alias-clusterlocal-{}.example", Uuid::new_v4().simple()); + let owner = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let added = db + .add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + assert!(matches!(added, AddHostAliasResult::Added(_))); + + let resolved = db + .lookup_community_by_host_or_alias(&alias_host) + .await + .expect("resolve alias") + .expect("alias resolves to a community"); + assert_eq!(resolved.id, created.id); + + // Case-insensitive, same as the primary-host index. + let resolved_upper = db + .lookup_community_by_host_or_alias(&alias_host.to_ascii_uppercase()) + .await + .expect("resolve upper-case alias") + .expect("alias resolves regardless of case"); + assert_eq!(resolved_upper.id, created.id); + + // A primary host must still resolve through the same fallback-aware + // lookup (the fallback must never shadow it). + let resolved_primary = db + .lookup_community_by_host_or_alias(&host) + .await + .expect("resolve primary host") + .expect("primary host still resolves"); + assert_eq!(resolved_primary.id, created.id); + + let listed = db + .list_community_host_aliases(created.id) + .await + .expect("list aliases"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].host, alias_host); + + assert!( + db.remove_community_host_alias(&alias_host, created.id) + .await + .expect("remove alias"), + "remove reports the row was deleted" + ); + assert!( + db.lookup_community_by_host_or_alias(&alias_host) + .await + .expect("resolve after removal") + .is_none(), + "removed alias must not resolve" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unknown_host_fails_closed_even_with_aliases_present() { + let db = setup_db().await; + let host = format!("unknown-fence-{}.example", Uuid::new_v4().simple()); + let owner = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let alias_host = format!("unknown-fence-alias-{}.example", Uuid::new_v4().simple()); + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + db.add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + + let unmapped = format!("totally-unmapped-{}.example", Uuid::new_v4().simple()); + assert!( + db.lookup_community_by_host_or_alias(&unmapped) + .await + .expect("lookup unmapped host") + .is_none(), + "a host that is neither a primary host nor an alias must fail closed" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn alias_colliding_with_a_primary_host_is_rejected() { + let db = setup_db().await; + let host_a = format!("collision-a-{}.example", Uuid::new_v4().simple()); + let host_b = format!("collision-b-{}.example", Uuid::new_v4().simple()); + let owner_a = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + let owner_b = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + let created_a = db + .create_community_with_owner(&host_a, owner_a) + .await + .expect("create community A"); + let CreateCommunityWithOwnerResult::Created(created_a) = created_a else { + panic!("expected new community A"); + }; + let created_b = db + .create_community_with_owner(&host_b, owner_b) + .await + .expect("create community B"); + let CreateCommunityWithOwnerResult::Created(created_b) = created_b else { + panic!("expected new community B"); + }; + + // The typed application-level guard rejects it... + let result = db + .add_community_host_alias(&host_b, created_a.id) + .await + .expect("add alias attempt"); + assert_eq!(result, AddHostAliasResult::PrimaryHostCollision); + + // ...and the database trigger is the belt-and-suspenders guard + // against the same collision if the application check is bypassed + // (e.g. a direct INSERT, as a misbehaving caller might attempt). + let trigger_result = + sqlx::query("INSERT INTO community_host_aliases (host, community_id) VALUES ($1, $2)") + .bind(&host_b) + .bind(created_a.id.as_uuid()) + .execute(&db.pool) + .await; + assert!( + trigger_result.is_err(), + "the database trigger must reject an alias colliding with a primary host" + ); + + // Community B's own host must still resolve to community B, not A. + let resolved = db + .lookup_community_by_host_or_alias(&host_b) + .await + .expect("resolve host B") + .expect("host B still resolves"); + assert_eq!(resolved.id, created_b.id); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn create_community_with_owner_is_atomic_and_create_only() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1674b0ec4d..eaefbf48ec 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -347,6 +347,7 @@ mod tests { "push_gateway_delivery_auth_replays", "push_gateway_delivery_request_replays", "product_feedback", + "community_host_aliases", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -560,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 24); + assert_eq!(migrations.len(), 25); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -879,6 +880,18 @@ mod tests { .to_lowercase() .contains("for update")); assert!(ttl_shared.contains("NEW.kind <> 9007")); + + // Host aliases: additional hostnames resolving to an existing + // community, guarded both directions against collision with a + // primary communities.host. + assert_eq!(migrations[24].version, 25); + let host_aliases = migrations[24].sql.as_str(); + assert!(host_aliases.contains("CREATE TABLE community_host_aliases")); + assert!(host_aliases + .contains("CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (lower(host))")); + assert!(host_aliases.contains("trg_community_host_aliases_no_primary_collision")); + assert!(host_aliases.contains("trg_communities_no_alias_collision")); + assert!(host_aliases.contains("'community_host_aliases'")); } #[test] diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 88b75f7d6e..9467b922ca 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -10,9 +10,10 @@ //! //! This module owns the *seam* (the [`HostResolver`] trait and the fail-closed //! [`bind_community`] helper) and the relay-side call site. The DB-backed -//! implementation that queries the `communities` table lives in `buzz-db` -//! (`Db::resolve_host`); the relay depends on the trait, not the query, so the -//! binding is testable without a database. +//! implementation that queries the `communities` table (falling back to +//! `community_host_aliases`) lives in `buzz-db` +//! (`Db::lookup_community_by_host_or_alias`); the relay depends on the trait, +//! not the query, so the binding is testable without a database. use buzz_core::tenant::{normalize_host, CommunityId, TenantContext}; @@ -123,14 +124,26 @@ pub async fn bind_deployment_community( pub use buzz_core::tenant::relay_url_authority; /// Production [`HostResolver`]: the relay resolves hosts against the durable -/// `communities` host map in Postgres. +/// `communities` host map in Postgres, falling back to `community_host_aliases` +/// when the host has no primary-host row. /// /// This is the *only* place the relay couples the row-zero seam to buzz-db. The /// trait keeps `bind_community` and every call site database-free and testable; -/// this impl is the thin adapter from buzz-db's `lookup_community_by_host` +/// this impl is the thin adapter from buzz-db's `lookup_community_by_host_or_alias` /// (which returns a `CommunityRecord`) to the seam's `CommunityId`. A lookup -/// that succeeds but finds no row is `Ok(None)` — fail-closed, never a default -/// tenant; a lookup that *fails* (DB unreachable) is `Err`, also fail-closed. +/// that succeeds but finds no row (in either table) is `Ok(None)` — fail-closed, +/// never a default tenant; a lookup that *fails* (DB unreachable) is `Err`, +/// also fail-closed. +/// +/// The alias fallback exists for deployments where a client can only reach the +/// relay through a hostname distinct from the community's primary host (e.g. +/// an in-cluster Service DNS name that a mesh sidecar routes by Host header, +/// which has no `communities.host` row of its own). Resolving via an alias +/// still returns only the `CommunityId` — [`bind_community`] binds the +/// *request's* normalized host into `TenantContext::host()`, never the +/// primary host a lookup happened to resolve through. NIP-98 `u`-URL and +/// NIP-42 `relay` checks therefore keep comparing against the host the client +/// actually used, alias or not. impl HostResolver for buzz_db::Db { type Error = buzz_db::DbError; @@ -139,7 +152,7 @@ impl HostResolver for buzz_db::Db { normalized_host: &str, ) -> Result, Self::Error> { Ok(self - .lookup_community_by_host(normalized_host) + .lookup_community_by_host_or_alias(normalized_host) .await? .map(|record| record.id)) } diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 15002142e4..aedfcc2dbb 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -30,6 +30,14 @@ //! one DB, two communities, provably isolated by `community_id` derived from the //! host — never from caller input. //! +//! The `host_aliases` module additionally needs a third host, +//! `RELAY_URL_ALIAS_A`, seeded as a `community_host_aliases` row for A (not a +//! `communities.host` row) via: +//! +//! ```text +//! RELAY_URL=http://a.localhost:3000 buzz-admin community alias add alias-a.localhost:3000 +//! ``` +//! //! # Status of each row //! //! A row is `todo!()`-stubbed until the lane it depends on lands on the @@ -60,6 +68,19 @@ fn url_unknown() -> String { .unwrap_or_else(|_| "http://unknown.localhost:3000".to_string()) } +/// A host that resolves to community A only via `community_host_aliases`, +/// never `communities.host` directly (see `host_aliases` module below). +/// +/// Requires seeding once against the running relay before the `host_aliases` +/// tests: `RELAY_URL= buzz-admin community alias add +/// ` — mirrors how `RELAY_URL_A`/`RELAY_URL_B` require +/// a pre-seeded two-community DB, just via the new alias table instead of +/// `communities.host`. +fn url_alias_a() -> String { + std::env::var("RELAY_URL_ALIAS_A") + .unwrap_or_else(|_| "http://alias-a.localhost:3000".to_string()) +} + /// Marker for a conformance obligation whose lane has not yet landed on the /// integration branch. Centralizes the "not yet wired" panic so the harvest of /// remaining work is one grep: `rg pending_lane conformance_multitenant.rs`. @@ -1641,6 +1662,199 @@ mod channels_membership { } } +// --------------------------------------------------------------------------- +// Host aliases: `community_host_aliases` fallback (relay-wiring) +// --------------------------------------------------------------------------- +mod host_aliases { + use super::*; + + use buzz_test_client::BuzzTestClient; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Convert any base form to `ws(s)://` for WS connect. + fn to_ws(base: &str) -> String { + if base.starts_with("ws://") || base.starts_with("wss://") { + base.trim_end_matches('/').to_string() + } else { + base.replace("https://", "wss://") + .replace("http://", "ws://") + .trim_end_matches('/') + .to_string() + } + } + + /// Convert any base form to `http(s)://` for REST. + fn to_http(base: &str) -> String { + if base.starts_with("http://") || base.starts_with("https://") { + base.trim_end_matches('/').to_string() + } else { + base.replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + } + + /// Same shape as `channels_membership::create_channel`: create an + /// open/stream channel in the community resolved by `http_base`. + async fn create_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String { + let client = reqwest::Client::new(); + let pubkey_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &format!("conformance-alias-{channel_uuid}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = client + .post(format!("{http_base}/events")) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit create-channel"); + assert!( + resp.status().is_success(), + "create-channel HTTP failed against {http_base}: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.expect("parse create-channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "create-channel not accepted against {http_base}: {body}" + ); + channel_uuid.to_string() + } + + async fn post_kind9( + client: &mut BuzzTestClient, + keys: &Keys, + channel_id: &str, + content: &str, + ) -> String { + let h_tag = Tag::parse(["h", channel_id]).unwrap(); + let event = EventBuilder::new(Kind::Custom(9), content) + .tags([h_tag]) + .sign_with_keys(keys) + .unwrap(); + let id_hex = event.id.to_hex(); + let ok = client.send_event(event).await.expect("send kind:9"); + assert!(ok.accepted, "kind:9 not accepted: {}", ok.message); + id_hex + } + + async fn query_kind9_in_channel( + http_base: &str, + pubkey_hex: &str, + channel_id: &str, + ) -> Vec { + let client = reqwest::Client::new(); + let filters = serde_json::json!([{ + "kinds": [9], + "#h": [channel_id], + "limit": 100, + }]); + let resp = client + .post(format!("{http_base}/query")) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&filters).unwrap()) + .send() + .await + .unwrap_or_else(|e| panic!("POST /query against {http_base} failed: {e}")); + assert!( + resp.status().is_success(), + "POST /query against {http_base} returned {}", + resp.status() + ); + resp.json().await.expect("parse /query JSON") + } + + /// Obligation: a host mapped only through `community_host_aliases` (never + /// `communities.host` directly) binds to the aliased community exactly + /// like a primary host would, and that binding does not leak into any + /// other community on the same relay process. + /// + /// This is the alias-table analogue of + /// [`super::channels_membership::same_channel_uuid_in_two_communities_is_isolated`]: + /// same shared-UUID-channel, distinct-content-per-community setup, except + /// the "A" side of the request now arrives over the alias host + /// (`url_alias_a()`) instead of A's primary host. If `resolve_host` ever + /// let the alias fall through to a default tenant, or resolved it to the + /// wrong community, the channel/message written over the alias host + /// would either fail to appear under community A's primary host, or + /// (worse) appear under B. + /// + /// # Mutate-bite + /// + /// Drop the alias fallback from `Db::lookup_community_by_host_or_alias` + /// (checking only `communities.host`) → the alias host's `bind_community` + /// call returns `UnmappedHost`, the `/events` POST over the alias host + /// 404s, and `create_channel`/`post_kind9` panic on non-success status. + /// Route the fallback to the *wrong* community instead of `NULL`-safe + /// `None` → the message posted "as A" over the alias host shows up under + /// B's query instead of A's, tripping the cross-leak assertion below. + #[tokio::test] + #[ignore] + async fn alias_host_binds_to_aliased_community_without_leaking_into_another() { + let http_alias_a = to_http(&url_alias_a()); + let ws_alias_a = to_ws(&url_alias_a()); + let http_a = to_http(&url_a()); + let http_b = to_http(&url_b()); + + let keys = Keys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + + // Create the channel over the alias host — if the alias resolved to + // no community (or the wrong one), this create would either 404 or + // land somewhere other than community A. + let channel_uuid = uuid::Uuid::new_v4(); + let chan = create_channel(&http_alias_a, &keys, channel_uuid).await; + + let content = format!("alias-bound message {channel_uuid}"); + let mut client_alias_a = BuzzTestClient::connect(&ws_alias_a, &keys) + .await + .expect("connect over alias host"); + let _id = post_kind9(&mut client_alias_a, &keys, &chan, &content).await; + client_alias_a + .disconnect() + .await + .expect("disconnect alias connection"); + + // Settle time, matching every other row in this file. + tokio::time::sleep(Duration::from_millis(500)).await; + + // (1) The alias host binds to community A: A's *primary* host sees + // the channel and message the alias host wrote. + let hits_a = query_kind9_in_channel(&http_a, &pubkey_hex, &chan).await; + assert_eq!( + hits_a.len(), + 1, + "community A's primary host did not see the message written over \ + its alias host — alias did not bind to A. hits: {hits_a:?}" + ); + assert_eq!( + hits_a[0]["content"].as_str().unwrap_or(""), + content, + "community A saw a message but not the one written over the alias host" + ); + + // (2) No leak into community B: B's host must see nothing for this + // channel id — the alias must not have bound to B, nor to some + // default/shared tenant both A and B's queries would also hit. + let hits_b = query_kind9_in_channel(&http_b, &pubkey_hex, &chan).await; + assert!( + hits_b.is_empty(), + "community B saw a message written over community A's alias host — \ + the alias leaked cross-community. hits: {hits_b:?}" + ); + } +} + // --------------------------------------------------------------------------- // Workflows / runs / approvals / webhooks / schedules (Mari+Max) // --------------------------------------------------------------------------- diff --git a/migrations/0025_community_host_aliases.sql b/migrations/0025_community_host_aliases.sql new file mode 100644 index 0000000000..23ce949d66 --- /dev/null +++ b/migrations/0025_community_host_aliases.sql @@ -0,0 +1,65 @@ +-- Host aliases: map additional hostnames onto an existing community without +-- touching `communities.host`. Conformance: row zero (`resolve_host`) still +-- checks `communities.host` first (crates/buzz-relay/src/tenant.rs); this +-- table is consulted only as a fallback, so an alias can never shadow or +-- outrank a primary host, and it never rewrites `TenantContext::host()` — the +-- request's own host stays authoritative for NIP-98 `u`-URL / NIP-42 `relay` +-- checks. +-- +-- Motivating case: in-cluster clients (e.g. Blox workstations) reach the +-- relay through a cluster-local Service DNS name that the Envoy mesh routes +-- by Host header, distinct from the community's externally-facing host. An +-- alias lets that cluster-local host resolve to the same community without +-- renaming the community's canonical host, which would rebind +-- `TenantContext::host()` and break existing NIP-98/NIP-42 checks for +-- external clients still using the original host. +-- +-- Like `communities`, this table is listed in `_operator_global_tables`: +-- host lookup must be globally unique across all communities (a host maps to +-- at most one community, alias or primary), so its unique index cannot lead +-- with `community_id`. +CREATE TABLE community_host_aliases ( + host VARCHAR(255) NOT NULL, + community_id UUID NOT NULL REFERENCES communities(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (lower(host)); +CREATE INDEX idx_community_host_aliases_community_id ON community_host_aliases (community_id); + +-- Guard both directions so an alias can never collide with a primary +-- `communities.host`, regardless of insertion order: a new/updated alias +-- cannot claim an existing primary host, and a new/renamed community cannot +-- claim an existing alias host. Cross-table checks can't be expressed as a +-- CHECK constraint, hence triggers (mirrors `channels_community_id_immutable` +-- in migration 0001). +CREATE FUNCTION community_host_aliases_no_primary_collision() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS (SELECT 1 FROM communities WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community primary host', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_community_host_aliases_no_primary_collision + BEFORE INSERT OR UPDATE ON community_host_aliases + FOR EACH ROW EXECUTE FUNCTION community_host_aliases_no_primary_collision(); + +CREATE FUNCTION communities_no_alias_collision() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS (SELECT 1 FROM community_host_aliases WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community host alias', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_communities_no_alias_collision + BEFORE INSERT OR UPDATE ON communities + FOR EACH ROW EXECUTE FUNCTION communities_no_alias_collision(); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('community_host_aliases', 'host alias map spans communities by design; global host uniqueness is enforced against communities.host via trigger, mirroring the communities registry itself'); diff --git a/schema/schema.sql b/schema/schema.sql index 1fd13941be..13d5c520bd 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -61,6 +61,59 @@ CREATE TABLE communities ( CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host)); +-- ── Community host aliases ─────────────────────────────────────────────────────── +-- Additional hostnames that resolve to an existing community without +-- touching `communities.host`. `resolve_host` checks `communities.host` +-- first and falls back here (crates/buzz-relay/src/tenant.rs); an alias can +-- never shadow a primary host and never rewrites `TenantContext::host()`. +-- +-- Motivating case: in-cluster clients reach the relay through a +-- cluster-local Service DNS name that the mesh routes by Host header, +-- distinct from the community's externally-facing host. +-- +-- Like `communities`, this table is OPERATOR-GLOBAL: host lookup must be +-- globally unique across all communities, so its unique index cannot lead +-- with `community_id`. Listed in the lint allowlist below. + +CREATE TABLE community_host_aliases ( + host VARCHAR(255) NOT NULL, + community_id UUID NOT NULL REFERENCES communities(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (lower(host)); +CREATE INDEX idx_community_host_aliases_community_id ON community_host_aliases (community_id); + +-- Guard both directions so an alias can never collide with a primary +-- communities.host, regardless of insertion order. +CREATE FUNCTION community_host_aliases_no_primary_collision() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS (SELECT 1 FROM communities WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community primary host', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_community_host_aliases_no_primary_collision + BEFORE INSERT OR UPDATE ON community_host_aliases + FOR EACH ROW EXECUTE FUNCTION community_host_aliases_no_primary_collision(); + +CREATE FUNCTION communities_no_alias_collision() RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS (SELECT 1 FROM community_host_aliases WHERE lower(host) = lower(NEW.host)) THEN + RAISE EXCEPTION 'host % is already a community host alias', NEW.host + USING ERRCODE = 'unique_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_communities_no_alias_collision + BEFORE INSERT OR UPDATE ON communities + FOR EACH ROW EXECUTE FUNCTION communities_no_alias_collision(); + -- ── Channels ────────────────────────────────────────────────────────────────── -- Conformance: "Channels and channel membership". `community_id` immutable. -- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique: @@ -744,7 +797,8 @@ CREATE TABLE _operator_global_tables ( INSERT INTO _operator_global_tables (table_name, reason) VALUES ('communities', 'the tenant registry itself; id IS the community key'), ('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'), - ('_operator_global_tables', 'the registry table itself'); + ('_operator_global_tables', 'the registry table itself'), + ('community_host_aliases', 'host alias map spans communities by design; global host uniqueness is enforced against communities.host via trigger, mirroring the communities registry itself'); -- NIP-PL effective lease state and durable wake outbox. Every key is led by -- community_id: client-provided origin is confirmation only, never routing. CREATE TABLE push_leases ( From 7785456cc2ff762fe56614a3c407cbaa2f7fb9d2 Mon Sep 17 00:00:00 2001 From: judeedwards Date: Thu, 23 Jul 2026 14:26:10 +0000 Subject: [PATCH 2/4] relay: harden host-alias collision guard and availability check Serialize the community/alias collision triggers with a shared advisory lock keyed on the lowercased host, closing a race where concurrent inserts to communities and community_host_aliases for the same host could both pass their EXISTS checks before either commits. Also make the operator availability endpoint check community_host_aliases so an alias-reserved host reports available: false instead of letting a create attempt fail on the trigger. Found by sq agents review. Co-Authored-By: Claude --- crates/buzz-db/src/lib.rs | 24 ++++++++ crates/buzz-relay/src/api/operator.rs | 69 +++++++++++++++++++++- migrations/0025_community_host_aliases.sql | 16 +++++ schema/schema.sql | 8 ++- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 0c2aed1db5..f723df3f7b 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -776,6 +776,30 @@ impl Db { .transpose() } + /// Returns whether `normalized_host` is reserved as a host alias for any + /// community, regardless of that community's archived state. + /// + /// Operator-plane counterpart to [`lookup_community_by_host_for_management`]: + /// a host stays reserved as an alias even if the community it points to is + /// archived (the alias-collision trigger from migration 0025 checks + /// `communities` unconditionally too), so an availability check that only + /// consulted the fallback-aware, archived-filtering + /// [`lookup_community_by_host_or_alias`] would report an alias-reserved + /// host as available and let a create attempt fail on the trigger instead + /// of the normal "host taken" response. + /// + /// [`lookup_community_by_host_for_management`]: Self::lookup_community_by_host_for_management + /// [`lookup_community_by_host_or_alias`]: Self::lookup_community_by_host_or_alias + pub async fn community_host_alias_exists(&self, normalized_host: &str) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_host_aliases WHERE lower(host) = lower($1))", + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + Ok(exists) + } + /// Adds a host alias resolving to `community_id`. /// /// Checks upfront for a collision with any community's primary host so diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874..65eab5504e 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -489,10 +489,21 @@ pub async fn community_availability( .await .map_err(|e| internal_error(&format!("check community availability: {e}")))?; + // A host can also be reserved as an alias of some other community (never + // as its own — the create path can't collide with its own alias). Check + // this even when `existing` is `Some` so the response is well-formed + // either way; the create/alias-add paths are the source of truth for + // which reservation wins. + let alias_taken = state + .db + .community_host_alias_exists(&normalized_host) + .await + .map_err(|e| internal_error(&format!("check community host alias reservation: {e}")))?; + Ok(Json(serde_json::json!({ "host": query.host, "normalized_host": normalized_host, - "available": existing.is_none(), + "available": existing.is_none() && !alias_taken, "community_id": existing.map(|record| record.id.to_string()), }))) } @@ -802,6 +813,62 @@ mod tests { assert_eq!(json.get("available").and_then(Value::as_bool), Some(true)); } + /// A host reserved only via `community_host_aliases` (never + /// `communities.host` itself) must report `available: false` — otherwise + /// a caller sees "available", then the create path's alias-collision + /// trigger turns the create into an internal error instead of the normal + /// "host taken" response. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn alias_reserved_host_is_not_available() { + let operator = Keys::generate(); + let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { + return; + }; + + let primary_host = format!("alias-owner-{}.example", Uuid::new_v4().simple()); + let owner_pubkey = "1111111111111111111111111111111111111111111111111111111111111a"; + let created = state + .db + .create_community_with_owner(&primary_host, owner_pubkey) + .await + .expect("create owning community"); + let buzz_db::CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let alias_host = format!("alias-target-{}.example", Uuid::new_v4().simple()); + state + .db + .add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + + let query = format!("host={alias_host}"); + let url = format!("http://{INGRESS_HOST}/operator/communities/availability?{query}"); + let auth = nip98_auth_header(&operator, &url, "GET", None); + + let response = build_router(state) + .oneshot( + Request::builder() + .uri(format!("/operator/communities/availability?{query}")) + .header(header::HOST, INGRESS_HOST) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let json = read_json(response).await; + assert_eq!( + json.get("available").and_then(Value::as_bool), + Some(false), + "alias-reserved host must not report available; got {json:?}" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unmapped_management_host_can_list_owned_communities() { diff --git a/migrations/0025_community_host_aliases.sql b/migrations/0025_community_host_aliases.sql index 23ce949d66..a573232a4a 100644 --- a/migrations/0025_community_host_aliases.sql +++ b/migrations/0025_community_host_aliases.sql @@ -33,8 +33,23 @@ CREATE INDEX idx_community_host_aliases_community_id ON community_host_aliases ( -- claim an existing alias host. Cross-table checks can't be expressed as a -- CHECK constraint, hence triggers (mirrors `channels_community_id_immutable` -- in migration 0001). +-- +-- Both triggers take a `pg_advisory_xact_lock` on the SAME key — derived only +-- from `lower(host)`, not the table — before their `EXISTS` read. Without it, +-- a community-create and an alias-add for the same host can run concurrently: +-- each runs its `EXISTS` check against the other's not-yet-committed row (MVCC +-- under READ COMMITTED does not see it), both pass, and both commit, leaving +-- the same host claimed on both sides — `lookup_community_by_host_or_alias` +-- would then silently prefer the primary row and the alias would never be +-- reachable, defeating the one-host-one-community invariant. The lock +-- serializes the two paths: the loser blocks until the winner's transaction +-- ends, then re-runs its `EXISTS` check under a fresh statement snapshot that +-- sees the winner's committed row (or absence of one). Lock key domain +-- 'buzz_host_claim:' is distinct from 'buzz_channel_ttl:' (migration 0024) and +-- 'buzz_push_gate:' (migration 0023). CREATE FUNCTION community_host_aliases_no_primary_collision() RETURNS TRIGGER AS $$ BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); IF EXISTS (SELECT 1 FROM communities WHERE lower(host) = lower(NEW.host)) THEN RAISE EXCEPTION 'host % is already a community primary host', NEW.host USING ERRCODE = 'unique_violation'; @@ -49,6 +64,7 @@ CREATE TRIGGER trg_community_host_aliases_no_primary_collision CREATE FUNCTION communities_no_alias_collision() RETURNS TRIGGER AS $$ BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); IF EXISTS (SELECT 1 FROM community_host_aliases WHERE lower(host) = lower(NEW.host)) THEN RAISE EXCEPTION 'host % is already a community host alias', NEW.host USING ERRCODE = 'unique_violation'; diff --git a/schema/schema.sql b/schema/schema.sql index 13d5c520bd..3ab7e32e48 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -85,9 +85,14 @@ CREATE UNIQUE INDEX idx_community_host_aliases_host ON community_host_aliases (l CREATE INDEX idx_community_host_aliases_community_id ON community_host_aliases (community_id); -- Guard both directions so an alias can never collide with a primary --- communities.host, regardless of insertion order. +-- communities.host, regardless of insertion order. Both triggers take a +-- pg_advisory_xact_lock keyed only on lower(host) (not the table) before +-- their EXISTS read, so a concurrent community-create and alias-add for the +-- same host serialize instead of both passing against each other's +-- not-yet-committed row (see migration 0025). CREATE FUNCTION community_host_aliases_no_primary_collision() RETURNS TRIGGER AS $$ BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); IF EXISTS (SELECT 1 FROM communities WHERE lower(host) = lower(NEW.host)) THEN RAISE EXCEPTION 'host % is already a community primary host', NEW.host USING ERRCODE = 'unique_violation'; @@ -102,6 +107,7 @@ CREATE TRIGGER trg_community_host_aliases_no_primary_collision CREATE FUNCTION communities_no_alias_collision() RETURNS TRIGGER AS $$ BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('buzz_host_claim:' || lower(NEW.host), 0)); IF EXISTS (SELECT 1 FROM community_host_aliases WHERE lower(host) = lower(NEW.host)) THEN RAISE EXCEPTION 'host % is already a community host alias', NEW.host USING ERRCODE = 'unique_violation'; From a5cc095d15b2a7203c1d81ab7b7d78289931b873 Mon Sep 17 00:00:00 2001 From: judeedwards Date: Thu, 23 Jul 2026 14:30:08 +0000 Subject: [PATCH 3/4] relay: fix create_community_with_owner surfacing alias collisions as errors A host reserved via community_host_aliases has no communities.host row, so ON CONFLICT (lower(host)) never catches it; the alias-collision trigger raises unique_violation from the INSERT instead. That was bubbling up as a raw database error rather than the typed HostExists result the operator API expects for a taken host. Catch the 23505 and fold it into HostExists. Found by sq agents review. Co-Authored-By: Claude --- crates/buzz-db/src/lib.rs | 63 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f723df3f7b..3fac50ab44 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1049,6 +1049,16 @@ impl Db { /// Holds a per-owner advisory lock while enforcing the ownership limit. /// Identical create retries return the original record; host collisions and /// limit failures remain distinguishable to the operator API. + /// + /// A host already reserved in `community_host_aliases` collides too: + /// `ON CONFLICT (lower(host))` only catches conflicts against + /// `communities`' own unique index, so an alias collision instead trips + /// migration 0025's `communities_no_alias_collision` trigger, which + /// raises a `23505` (`unique_violation`) from the `BEFORE INSERT` and + /// never reaches `ON CONFLICT`. That's caught below and folded into the + /// same [`CreateCommunityWithOwnerResult::HostExists`] outcome, so + /// callers see one "host taken" case regardless of which table claimed + /// it first. pub async fn create_community_with_owner( &self, normalized_host: &str, @@ -1074,7 +1084,16 @@ impl Db { ) .bind(normalized_host) .fetch_optional(&mut *tx) - .await?; + .await; + + let row = match row { + Ok(row) => row, + Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23505") => { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::HostExists); + } + Err(err) => return Err(err.into()), + }; let (id, host) = if let Some(row) = row { let id: Uuid = row.try_get("id")?; @@ -5123,6 +5142,48 @@ mod tests { ); } + /// A host already reserved via `community_host_aliases` never has a + /// `communities.host` row of its own, so `ON CONFLICT (lower(host))` + /// can't catch the collision — it's the `communities_no_alias_collision` + /// trigger that rejects the `INSERT`. That must still surface as the + /// typed `HostExists` outcome, not a raw database error. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_treats_alias_reserved_host_as_host_exists() { + let db = setup_db().await; + let owner_host = format!("alias-owner-{}.example", Uuid::new_v4().simple()); + let owner = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + let created = db + .create_community_with_owner(&owner_host, owner) + .await + .expect("create owning community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let alias_host = format!("alias-target-{}.example", Uuid::new_v4().simple()); + db.add_community_host_alias(&alias_host, created.id) + .await + .expect("add alias"); + + let other = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let collision = db + .create_community_with_owner(&alias_host, other) + .await + .expect("collision result should not be a raw DB error"); + assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); + + // The failed create must not have poisoned the pool connection or + // left the alias's owning community mutated. + let aliases = db + .list_community_host_aliases(created.id) + .await + .expect("list aliases after collision"); + assert_eq!(aliases.len(), 1); + assert_eq!(aliases[0].host, alias_host); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn unarchive_community_owned_by_restores_admission_idempotently() { From 6d1d7bf1827668094674ae4a832c14aedd84cecd Mon Sep 17 00:00:00 2001 From: judeedwards Date: Thu, 23 Jul 2026 14:39:36 +0000 Subject: [PATCH 4/4] relay: cover remaining alias-collision paths flagged by review Two more paths could hit an alias-reserved host without translating migration 0025's trigger error into something callers can act on: - ensure_configured_community (the operator API's legacy convergence mode, and startup community seeding) let the trigger's raw database error propagate as an internal 500 instead of the usual "community already exists" conflict. Added DbError::HostAliasCollision and mapped it in community_provisioning.rs. - resolve_admin_tenant in buzz-admin only checked communities.host, so running buzz-admin with RELAY_URL set to an alias (the exact in-cluster case this feature exists for) couldn't resolve its own tenant. Switched it to lookup_community_by_host_or_alias. Found by a third sq agents review pass; this closes out all findings from all three passes. Co-Authored-By: Claude --- crates/buzz-admin/src/main.rs | 27 +++++++---- crates/buzz-db/src/error.rs | 6 +++ crates/buzz-db/src/lib.rs | 48 ++++++++++++++++++- .../src/handlers/community_provisioning.rs | 12 +++-- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 4f330229bc..058ab8f318 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -564,10 +564,14 @@ async fn connect_db() -> Result { /// /// `buzz-admin` runs inside the relay container (`compose exec relay /// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same -/// single community against the durable `communities` host map. This is -/// deliberately NOT a default tenant: an unmapped host fails closed with an -/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is -/// single-community per invocation — there is no cross-community sweep. +/// single community the relay's own `HostResolver` would (`communities.host`, +/// falling back to `community_host_aliases`) — including when `RELAY_URL` +/// itself is only reachable as an alias, e.g. running `buzz-admin` from an +/// in-cluster host that has no primary `communities.host` row of its own. +/// This is deliberately NOT a default tenant: an unmapped host fails closed +/// with an error, mirroring the relay's own `bind_community` row-zero seam. +/// The CLI is single-community per invocation — there is no cross-community +/// sweep. async fn resolve_admin_tenant(db: &Db) -> Result { let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); @@ -579,14 +583,17 @@ async fn resolve_admin_tenant(db: &Db) -> Result { // — and `wss://relay.example:8443` would resolve `relay.example`. Sharing // the helper keeps buzz-admin byte-identical to the community startup seeds. let host = relay_url_authority(&relay_url); - let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| { - anyhow::anyhow!( - "RELAY_URL host '{host}' is not mapped to a community.\n\ + let record = db + .lookup_community_by_host_or_alias(&host) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "RELAY_URL host '{host}' is not mapped to a community.\n\ buzz-admin operates on the configured relay's community; ensure the \ relay has started and seeded its community (or set RELAY_URL to a \ - mapped host)." - ) - })?; + mapped host or alias)." + ) + })?; Ok(TenantContext::resolved(record.id, record.host)) } diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index f8b8a2eb56..bfa601eef5 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -45,6 +45,12 @@ pub enum DbError { #[error("invalid data: {0}")] InvalidData(String), + /// The requested host is already reserved as a `community_host_aliases` + /// row for a different community, so it cannot become a primary + /// `communities.host` too. + #[error("host is already reserved as a community host alias: {0}")] + HostAliasCollision(String), + /// A stored timestamp value could not be interpreted. #[error("invalid timestamp: {0}")] InvalidTimestamp(i64), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 3fac50ab44..03cd307f65 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1017,6 +1017,14 @@ impl Db { /// This is the startup/config seeding path for N=1 deployments. Migrations /// create the schema only; deployment-specific hosts are not hardcoded into /// schema history. + /// + /// A host already reserved in `community_host_aliases` never gets a + /// `communities.host` row of its own, so `ON CONFLICT (lower(host))` + /// can't catch that case — migration 0025's `communities_no_alias_collision` + /// trigger rejects the `INSERT` instead, raising `23505`. That's caught + /// below and surfaced as [`DbError::HostAliasCollision`] so callers (the + /// operator provisioning API's legacy convergence path in particular) get + /// a distinguishable, mappable error instead of a raw database error. pub async fn ensure_configured_community( &self, normalized_host: &str, @@ -1031,7 +1039,15 @@ impl Db { ) .bind(normalized_host) .fetch_one(&self.pool) - .await?; + .await; + + let row = match row { + Ok(row) => row, + Err(sqlx::Error::Database(db_err)) if db_err.code().as_deref() == Some("23505") => { + return Err(DbError::HostAliasCollision(normalized_host.to_string())); + } + Err(err) => return Err(err.into()), + }; let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; @@ -5328,6 +5344,36 @@ mod tests { assert_eq!(second.host, host); } + /// The legacy convergence path (used by the operator API's `create_only: + /// false` mode and by startup seeding) must also translate an + /// alias-reserved host into a distinguishable error rather than letting + /// migration 0025's trigger raise a raw database error. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_configured_community_rejects_alias_reserved_host() { + let db = setup_db().await; + let owner_host = format!("ensure-alias-owner-{}.example", Uuid::new_v4().simple()); + + let owner = db + .ensure_configured_community(&owner_host) + .await + .expect("create owning community"); + + let alias_host = format!("ensure-alias-target-{}.example", Uuid::new_v4().simple()); + db.add_community_host_alias(&alias_host, owner.id) + .await + .expect("add alias"); + + let err = db + .ensure_configured_community(&alias_host) + .await + .expect_err("alias-reserved host must not silently become a primary host"); + assert!( + matches!(err, DbError::HostAliasCollision(ref h) if h == &alias_host), + "expected HostAliasCollision, got {err:?}" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn list_communities_owned_by_returns_only_owner_rows() { diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea..87cd10a8ed 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -318,11 +318,13 @@ pub async fn provision_community( // Legacy convergence mode remains available to deployment operators and // startup tooling. Clients provisioning on behalf of end users must use // create_only so an existing owner can never be rotated by a create race. - let record = state - .db - .ensure_configured_community(&request.host) - .await - .map_err(|e| format!("failed to create community: {e}"))?; + let record = match state.db.ensure_configured_community(&request.host).await { + Ok(record) => record, + Err(buzz_db::DbError::HostAliasCollision(_)) => { + return Err("community already exists".to_string()); + } + Err(e) => return Err(format!("failed to create community: {e}")), + }; if let Some(owner_hex) = &initial_owner { state