Files
buzz/crates/buzz-relay/src/tenant.rs
Tyler 14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00

334 lines
15 KiB
Rust

//! Row-zero host binding: resolve the request's community from the connection
//! host *before* any handler observes tenant data.
//!
//! Conformance "row zero": `req.community = resolve_host(connection.host)`,
//! bound at connection establishment. The host is the authoritative selector;
//! an unknown or unmapped host fails closed with a generic rejection and never
//! falls through to a default tenant. A client-supplied community (e.g. a token
//! stamp or an `h` tag) may narrow or authenticate authority but can never
//! override the host-derived community.
//!
//! 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.
use buzz_core::tenant::{normalize_host, CommunityId, TenantContext};
/// Resolves a normalized connection host to its community, or `None` when the
/// host maps to no community on this deployment.
///
/// Implementors MUST treat the input as already normalized by
/// [`buzz_core::tenant::normalize_host`] — [`bind_community`] guarantees that,
/// so the stored `communities.host` key and the lookup key agree by
/// construction (the column is `UNIQUE(lower(host))`, frozen in migration
/// `0001`).
///
/// Uses a native `async fn` in trait (no `async-trait` dependency). The relay
/// holds a concrete resolver (`Db`), so callers are generic over `R:
/// HostResolver` and never need `dyn` dispatch.
pub trait HostResolver: Send + Sync {
/// The error type surfaced when the lookup itself fails (e.g. the database
/// is unreachable). This is distinct from "host not mapped", which is a
/// successful lookup returning `None`.
type Error;
/// Look up the community for an already-normalized host.
///
/// `Ok(Some(_))` — host maps to a community.
/// `Ok(None)` — host is valid input but maps to nothing (fail closed).
/// `Err(_)` — the lookup could not be performed.
fn resolve_host(
&self,
normalized_host: &str,
) -> impl std::future::Future<Output = Result<Option<CommunityId>, Self::Error>> + Send;
}
/// The outcome of attempting to bind a request to a community.
#[derive(Debug)]
pub enum BindError<E> {
/// The host did not map to any community on this deployment. Callers MUST
/// reject the request with a *generic* error — never echo the host back or
/// distinguish "unmapped" from other failures, so an unauthenticated
/// caller cannot probe which hosts exist.
UnmappedHost,
/// The resolution lookup itself failed (e.g. database error). Treated as
/// fail-closed: the request is rejected, never admitted to a default tenant.
Lookup(E),
}
/// Bind a raw connection host to a [`TenantContext`], failing closed.
///
/// This is the single row-zero entry point. It normalizes the host with the
/// one shared rule, resolves it, and on any non-success (unmapped *or* lookup
/// error) returns a [`BindError`] the caller turns into a generic rejection.
/// There is deliberately no path that yields a default or fallback community.
///
/// The returned [`TenantContext`] carries the *normalized* host, so downstream
/// NIP-05 / audit labelling and the NIP-98 `u`-host check all see the same
/// canonical form the community was resolved from.
pub async fn bind_community<R: HostResolver>(
resolver: &R,
raw_host: &str,
) -> Result<TenantContext, BindError<R::Error>> {
let host = normalize_host(raw_host);
// Inv_RowZero (host-binding seam): an empty raw_host carries no community
// evidence — there is no `connection.host` to resolve, so no community can
// be derived from it. Fail closed BEFORE the resolver lookup. The schema
// does not forbid an `host = ''` row in `communities`, so without this
// guard a request with a missing/whitespace-only Host header would silently
// bind to a misconfigured empty-host community. Reuse `UnmappedHost` (not a
// distinct variant) so the rejection is byte-identical to any other unmapped
// host — an unauthenticated caller cannot probe for an empty-host row.
if host.is_empty() {
return Err(BindError::UnmappedHost);
}
match resolver.resolve_host(&host).await {
Ok(Some(community)) => Ok(TenantContext::resolved(community, host)),
Ok(None) => Err(BindError::UnmappedHost),
Err(e) => Err(BindError::Lookup(e)),
}
}
/// Resolve the deployment's own community from the configured relay URL host.
///
/// For server-internal paths that have no inbound request `Host` header — the
/// git Smart-HTTP transport, the localhost pre-receive hook callback, the
/// workflow execution sink, and startup tasks — the tenant cannot come from a
/// connection. A relay deployment serves a single canonical host (its
/// `relay_url`), so we resolve that host through the same fail-closed
/// [`bind_community`] path. This is deliberately NOT a default/fallback
/// community: an unmapped `relay_url` host returns the same [`BindError`] as
/// any other unmapped host.
pub async fn bind_deployment_community<R: HostResolver>(
resolver: &R,
relay_url: &str,
) -> Result<TenantContext, BindError<R::Error>> {
bind_community(resolver, &buzz_core::tenant::relay_url_authority(relay_url)).await
}
/// Extract the relay URL authority in the same normalized shape as request
/// `Host` headers and `communities.host`: host plus an explicit non-default
/// port, if present.
///
/// `pub` so startup ([`crate::main`], a separate binary crate) can seed the
/// deployment's own community under the *same* normalized host that live request
/// resolution ([`bind_community`]) will derive — the two must agree or the
/// bootstrapped owner lands in a community no request ever resolves to.
///
/// This is a thin re-export of [`buzz_core::tenant::relay_url_authority`]: the
/// canonical implementation lives in `buzz-core` so the relay seam *and* the
/// `buzz-admin` CLI derive a byte-identical authority (same port/IPv6 handling).
pub use buzz_core::tenant::relay_url_authority;
/// Production [`HostResolver`]: the relay resolves hosts against the durable
/// `communities` host map in Postgres.
///
/// 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`
/// (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.
impl HostResolver for buzz_db::Db {
type Error = buzz_db::DbError;
async fn resolve_host(
&self,
normalized_host: &str,
) -> Result<Option<CommunityId>, Self::Error> {
Ok(self
.lookup_community_by_host(normalized_host)
.await?
.map(|record| record.id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use uuid::Uuid;
/// In-memory resolver over a fixed host→community map, so the binding seam
/// is testable without a database.
struct MapResolver {
map: HashMap<String, CommunityId>,
fail: bool,
}
impl HostResolver for MapResolver {
type Error = &'static str;
async fn resolve_host(
&self,
normalized_host: &str,
) -> Result<Option<CommunityId>, Self::Error> {
if self.fail {
return Err("db down");
}
Ok(self.map.get(normalized_host).copied())
}
}
fn resolver_with(host: &str, id: u128) -> MapResolver {
let mut map = HashMap::new();
map.insert(
host.to_string(),
CommunityId::from_uuid(Uuid::from_u128(id)),
);
MapResolver { map, fail: false }
}
#[tokio::test]
async fn maps_known_host_to_its_community() {
let r = resolver_with("relay.example", 1);
let ctx = bind_community(&r, "relay.example").await.expect("bound");
assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(1));
assert_eq!(ctx.host(), "relay.example");
}
#[tokio::test]
async fn normalizes_before_lookup_so_variants_resolve_to_one_tenant() {
// The map holds the canonical form; case/dot/default-port variants must
// all bind to the same community (they cannot split a tenant).
let r = resolver_with("relay.example", 7);
for variant in ["RELAY.EXAMPLE", "relay.example.", "relay.example:443"] {
let ctx = bind_community(&r, variant)
.await
.unwrap_or_else(|_| panic!("variant {variant:?} should bind"));
assert_eq!(
ctx.community().as_uuid(),
&Uuid::from_u128(7),
"variant {variant:?}"
);
assert_eq!(ctx.host(), "relay.example", "variant {variant:?}");
}
}
#[tokio::test]
async fn deployment_url_keeps_nondefault_port_for_lookup() {
let r = resolver_with("localhost:3000", 42);
let ctx = bind_deployment_community(&r, "ws://localhost:3000")
.await
.expect("deployment host should bind with non-default port");
assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(42));
assert_eq!(ctx.host(), "localhost:3000");
let wrong = resolver_with("localhost", 42);
let err = bind_deployment_community(&wrong, "ws://localhost:3000")
.await
.unwrap_err();
assert!(matches!(err, BindError::UnmappedHost));
}
#[tokio::test]
async fn deployment_url_normalizes_default_ports() {
let r = resolver_with("relay.example", 9);
for url in ["ws://relay.example:80", "wss://relay.example:443"] {
let ctx = bind_deployment_community(&r, url)
.await
.unwrap_or_else(|_| panic!("url {url:?} should bind"));
assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(9));
assert_eq!(ctx.host(), "relay.example", "url {url:?}");
}
}
#[test]
fn relay_url_authority_preserves_ipv6_brackets() {
assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000");
assert_eq!(relay_url_authority("wss://[::1]:443"), "[::1]");
}
#[tokio::test]
async fn unmapped_host_fails_closed() {
let r = resolver_with("relay.example", 1);
let err = bind_community(&r, "evil.example").await.unwrap_err();
assert!(matches!(err, BindError::UnmappedHost));
}
#[tokio::test]
async fn lookup_error_fails_closed_not_default_tenant() {
let r = MapResolver {
map: HashMap::new(),
fail: true,
};
let err = bind_community(&r, "relay.example").await.unwrap_err();
assert!(matches!(err, BindError::Lookup("db down")));
}
mod redteam_attack2 {
use super::*;
/// RED gate. Configures a resolver with an `""→CommunityId` mapping
/// (the schema permits it; no CHECK against empty host exists), then
/// asks `bind_community` to bind an empty raw_host as a request with
/// a missing/invalid Host header would. Today this returns
/// `Ok(TenantContext{community=X})` — the fence collapses to the
/// misconfigured row. The fix: short-circuit in `bind_community` so
/// that `normalize_host(raw_host).is_empty()` returns
/// `Err(BindError::UnmappedHost)` before any resolver lookup.
///
/// Generic-rejection note: we reuse `UnmappedHost` (not a new
/// `EmptyHost` variant) so the door's response is byte-identical to
/// any other unmapped host — an unauthenticated caller cannot probe
/// whether the deployment has an empty-host row.
///
/// Delete this `#[ignore]` when the fix lands; verified RED with
/// `cargo test -p buzz-relay --include-ignored
/// tenant::tests::redteam_attack2::empty_raw_host_fails_closed_even_if_db_has_empty_host_row`
#[tokio::test]
async fn empty_raw_host_fails_closed_even_if_db_has_empty_host_row() {
// Simulate operator misconfig / buggy migration: an empty-host row
// exists in `communities`. The schema does not forbid this.
let r = resolver_with("", 0xdeadbeef);
// A request with a missing or unreadable Host header reaches
// `bind_community` with raw_host = "" (router.rs:169-172). The
// fence must reject — the request never supplied a host.
let err = bind_community(&r, "").await.expect_err(
"Inv_RowZero: an empty raw_host carries no community evidence; \
bind_community must fail closed regardless of the host map",
);
assert!(
matches!(err, BindError::UnmappedHost),
"fence must produce a generic UnmappedHost (no info leak about \
whether an empty-host row exists); got {err:?}",
);
}
/// RED gate. Same property, whitespace-only host: `normalize_host`
/// trims to empty (`buzz-core::tenant::normalize_host_empty_stays_empty`),
/// so this is the same fence collapse via a different raw input.
///
/// Delete `#[ignore]` when the fix lands.
#[tokio::test]
async fn whitespace_only_raw_host_fails_closed_even_if_db_has_empty_host_row() {
let r = resolver_with("", 0xdeadbeef);
let err = bind_community(&r, " ").await.expect_err(
"Inv_RowZero: whitespace-only raw_host normalizes to empty \
(see buzz-core::tenant::normalize_host) and carries no \
community evidence",
);
assert!(
matches!(err, BindError::UnmappedHost),
"fence must produce a generic UnmappedHost; got {err:?}",
);
}
/// Negative control: a *non-empty* unmapped host must still fail
/// closed (this already passes — included so the redteam_attack2
/// module documents both shapes of the fence's intended behavior and
/// catches a fix that accidentally over-narrows to only-empty).
#[tokio::test]
async fn non_empty_unmapped_host_still_fails_closed_after_fix() {
let r = resolver_with("", 0xdeadbeef);
let err = bind_community(&r, "evil.example").await.unwrap_err();
assert!(matches!(err, BindError::UnmappedHost));
}
}
}