mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
feat(relay): row-zero host-binding seam (HostResolver + fail-closed bind)
Conformance row zero: req.community = resolve_host(connection.host), bound before any handler observes tenant data. This lands the relay-side seam: - HostResolver trait (native async fn, no async-trait dep) — buzz-db's Db::resolve_host satisfies it; the relay depends on the trait, not the query, so the binding is testable without a database. Callers are generic over R, no dyn dispatch (the relay holds a concrete Db). - bind_community(): normalizes the host with the one shared rule, resolves it, and fails closed on BOTH unmapped host AND lookup error — there is no path that yields a default/fallback community. UnmappedHost is a distinct variant the call site turns into a GENERIC reject (no host echo, no unmapped-vs-error distinction) so an unauthenticated caller can't probe which hosts exist. - TenantContext carries the normalized host, so downstream NIP-05/audit labelling and the NIP-98 u-host check all see the canonical form the community was resolved from. Tests (4, green) cover known-host bind, variant normalization (case/dot/ default-port can't split a tenant), unmapped fail-closed, and lookup-error fail-closed-not-default. Adversarially verified: mutating the None arm to fall through to a nil default community turns unmapped_host_fails_closed RED. Seam contract for the buzz-db lane (Mari): Db::resolve_host(&self, normalized_host: &str) -> Result<Option<CommunityId>, DbError>, a SELECT id FROM communities WHERE host = $1 on the normalized key. Router call site (nip11_or_ws_handler) + threading TenantContext through handle_connection land next in this lane. (cherry picked from commit 0be8532e0e94e5ecd6529f2f3f52255dd36f6009) Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,8 @@ pub mod router;
|
||||
pub mod state;
|
||||
/// Subscription registry with (channel, kind) fan-out index.
|
||||
pub mod subscription;
|
||||
/// Row-zero host binding: resolve the request community from the connection host.
|
||||
pub mod tenant;
|
||||
/// Webhook secret generation and constant-time comparison.
|
||||
pub mod webhook_secret;
|
||||
/// Workflow action sink — relay-side implementation of [`buzz_workflow::ActionSink`].
|
||||
|
||||
160
crates/buzz-relay/src/tenant.rs
Normal file
160
crates/buzz-relay/src/tenant.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! 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);
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user