mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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>
276 lines
11 KiB
Rust
276 lines
11 KiB
Rust
//! Tenant identity: the server-resolved community key carried on every scoped path.
|
|
//!
|
|
//! These types live in `buzz-core` (zero I/O deps) so the DB, auth, pub/sub,
|
|
//! search, audit, media, and relay-wiring layers all name a community the same
|
|
//! way without depending on each other.
|
|
//!
|
|
//! ## The fence
|
|
//!
|
|
//! The whole multi-tenant safety story rests on one invariant from the formal
|
|
//! model (conformance "row zero"): a request's community is *resolved from the
|
|
//! connection host by the server*, never supplied or influenced by the client.
|
|
//!
|
|
//! [`TenantContext`] expresses that invariant in the type system as far as the
|
|
//! type system can carry it: there is no `Default`, no `Deserialize`, and no
|
|
//! way to *parse* a community from client input. A `CommunityId` only ever
|
|
//! comes from host resolution or from a DB row the server already scoped.
|
|
//!
|
|
//! This is a **lint-and-review fence, not a compiler fence.**
|
|
//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the
|
|
//! host-resolution path (in another crate) can call them — which means a
|
|
//! determined caller elsewhere *could* call them too. The migration-lint
|
|
//! harness forbids constructing a `TenantContext` outside host resolution and
|
|
//! tests; the type only removes the *accidental* path (deserializing a
|
|
//! client-chosen community), and review/lint closes the deliberate one. We say
|
|
//! this plainly rather than overclaim a guarantee the `pub` API doesn't give.
|
|
|
|
use std::fmt;
|
|
use uuid::Uuid;
|
|
|
|
/// A community: the first-class tenant key on every scoped row.
|
|
///
|
|
/// Opaque UUID newtype. Equality and ordering are the underlying UUID's.
|
|
/// There is deliberately no `community_id` parsed from client input anywhere;
|
|
/// a `CommunityId` only ever originates from host resolution or from a DB row
|
|
/// the server already scoped.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
pub struct CommunityId(Uuid);
|
|
|
|
impl CommunityId {
|
|
/// Wrap a UUID that the server has already established as a community id
|
|
/// (e.g. read back from the `communities` table during host resolution).
|
|
///
|
|
/// This is intentionally not a parse-from-client entry point: callers must
|
|
/// already hold a server-trusted UUID.
|
|
pub const fn from_uuid(id: Uuid) -> Self {
|
|
Self(id)
|
|
}
|
|
|
|
/// The underlying UUID, for DB binds and Redis key construction.
|
|
pub const fn as_uuid(&self) -> &Uuid {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for CommunityId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
fmt::Display::fmt(&self.0, f)
|
|
}
|
|
}
|
|
|
|
/// The resolved tenant of an in-flight request, bound once at connection /
|
|
/// request establishment before any handler observes tenant data.
|
|
///
|
|
/// Carried by reference (`&TenantContext`) through every scoped call. This is
|
|
/// the *only* way to name a community downstream, and it cannot be constructed
|
|
/// from client input — see the module-level "fence" note.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TenantContext {
|
|
community: CommunityId,
|
|
host: String,
|
|
}
|
|
|
|
impl TenantContext {
|
|
/// Construct a context from a completed host resolution.
|
|
///
|
|
/// Call this *only* from the host-resolution path (the function that maps a
|
|
/// connection's host to a `communities` row). Everywhere else takes
|
|
/// `&TenantContext` and reads it; nothing else mints one.
|
|
pub fn resolved(community: CommunityId, host: impl Into<String>) -> Self {
|
|
Self {
|
|
community,
|
|
host: host.into(),
|
|
}
|
|
}
|
|
|
|
/// The community every scoped operation under this request must use.
|
|
pub const fn community(&self) -> CommunityId {
|
|
self.community
|
|
}
|
|
|
|
/// The host that resolved to this community.
|
|
///
|
|
/// Authoritative for the NIP-05 domain and audit labelling; never re-derive
|
|
/// the community from it downstream — the community is already fixed.
|
|
pub fn host(&self) -> &str {
|
|
&self.host
|
|
}
|
|
}
|
|
|
|
/// Normalize a connection `Host` into the canonical form used as the community
|
|
/// lookup key.
|
|
///
|
|
/// This is the *one* normalization rule shared by both sides of the fence:
|
|
/// the `communities.host` column is stored already-normalized, and host
|
|
/// resolution normalizes the incoming `Host` header with this same function
|
|
/// before looking it up. Because both sides agree by construction,
|
|
/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to
|
|
/// the one community — they can never split into distinct tenants.
|
|
///
|
|
/// Rules (host only — the caller has already split off any path/scheme):
|
|
/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986);
|
|
/// - strip a single trailing dot (the FQDN root label);
|
|
/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept,
|
|
/// since a deployment may legitimately serve different communities on
|
|
/// different ports of the same name.
|
|
///
|
|
/// The input is trimmed of surrounding whitespace. An empty result (e.g. the
|
|
/// caller passed `""`) is returned as-is; resolution treats an empty or
|
|
/// unmapped host as a fail-closed rejection, never a default tenant.
|
|
#[must_use]
|
|
pub fn normalize_host(host: &str) -> String {
|
|
let host = host.trim();
|
|
let mut host = host.to_ascii_lowercase();
|
|
// Strip default ports. We only touch a `:port` suffix that is exactly a
|
|
// default port, so IPv6 literals like `[::1]` (which contain colons but no
|
|
// trailing `:80`/`:443`) are left intact.
|
|
if let Some(stripped) = host
|
|
.strip_suffix(":443")
|
|
.or_else(|| host.strip_suffix(":80"))
|
|
{
|
|
host = stripped.to_string();
|
|
}
|
|
// Strip a single trailing FQDN-root dot.
|
|
if let Some(stripped) = host.strip_suffix('.') {
|
|
host = stripped.to_string();
|
|
}
|
|
host
|
|
}
|
|
|
|
/// Extract the authority (host plus an explicit non-default port, if present)
|
|
/// from a relay URL in the same normalized shape as request `Host` headers and
|
|
/// `communities.host`.
|
|
///
|
|
/// Shared by the relay's host-resolution seam (startup community seeding and
|
|
/// the deployment-community bind), the relay's `bind_deployment_community`, and
|
|
/// the `buzz-admin` CLI's tenant resolution. All of these must derive the
|
|
/// *byte-identical* authority that live request resolution
|
|
/// ([`crate::tenant::normalize_host`]) produces from an inbound `Host`, or a
|
|
/// bootstrapped/looked-up community lands under a host no request resolves to.
|
|
///
|
|
/// In particular this preserves an explicit non-default port (`relay:8443` →
|
|
/// `relay:8443`) and IPv6 brackets (`[::1]:3000`) — both of which a naive
|
|
/// `Url::host_str()` drops. Returns the empty string when `relay_url` has no
|
|
/// parseable host (the caller fails closed on empty).
|
|
#[must_use]
|
|
pub fn relay_url_authority(relay_url: &str) -> String {
|
|
let Ok(url) = url::Url::parse(relay_url) else {
|
|
return String::new();
|
|
};
|
|
let Some(host) = url.host() else {
|
|
return String::new();
|
|
};
|
|
let host = match host {
|
|
url::Host::Domain(domain) => domain.to_string(),
|
|
url::Host::Ipv4(addr) => addr.to_string(),
|
|
url::Host::Ipv6(addr) => format!("[{addr}]"),
|
|
};
|
|
let authority = match url.port() {
|
|
Some(port) => format!("{host}:{port}"),
|
|
None => host,
|
|
};
|
|
normalize_host(&authority)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn community_id_roundtrips_uuid() {
|
|
let u = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788);
|
|
let c = CommunityId::from_uuid(u);
|
|
assert_eq!(c.as_uuid(), &u);
|
|
assert_eq!(c.to_string(), u.to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn tenant_context_exposes_resolution_inputs() {
|
|
let u = Uuid::from_u128(1);
|
|
let ctx = TenantContext::resolved(CommunityId::from_uuid(u), "relay.example");
|
|
assert_eq!(ctx.community().as_uuid(), &u);
|
|
assert_eq!(ctx.host(), "relay.example");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_host_collapses_tenant_split_variants() {
|
|
// All of these are the SAME tenant and must normalize identically —
|
|
// this is the property that stops accidental split-tenant.
|
|
let canonical = "relay.example";
|
|
for variant in [
|
|
"relay.example",
|
|
"Relay.Example",
|
|
"RELAY.EXAMPLE",
|
|
"relay.example.", // trailing FQDN root dot
|
|
"relay.example:443", // default https port
|
|
"relay.example:80", // default http port
|
|
"Relay.Example.:443",
|
|
" relay.example ", // surrounding whitespace
|
|
] {
|
|
assert_eq!(normalize_host(variant), canonical, "variant {variant:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_host_keeps_nondefault_port() {
|
|
// A non-default port is a legitimate distinct selector — keep it.
|
|
assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443");
|
|
assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_host_leaves_ipv6_literal_intact() {
|
|
// IPv6 literals contain colons but no trailing default-port suffix.
|
|
assert_eq!(normalize_host("[::1]"), "[::1]");
|
|
assert_eq!(normalize_host("[::1]:443"), "[::1]");
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_host_empty_stays_empty() {
|
|
// Empty / whitespace-only resolves to empty; resolution fails closed.
|
|
assert_eq!(normalize_host(""), "");
|
|
assert_eq!(normalize_host(" "), "");
|
|
}
|
|
|
|
#[test]
|
|
fn relay_url_authority_keeps_explicit_nondefault_port() {
|
|
// The default dev seed: startup, bind_deployment_community, and
|
|
// buzz-admin must all derive `localhost:3000` (NOT bare `localhost`),
|
|
// or the admin lookup misses the community startup seeded.
|
|
assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000");
|
|
assert_eq!(
|
|
relay_url_authority("wss://relay.example:8443"),
|
|
"relay.example:8443"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relay_url_authority_collapses_default_ports() {
|
|
// Default ports collapse to the bare host, matching how an inbound
|
|
// `Host` header for the same deployment normalizes.
|
|
assert_eq!(
|
|
relay_url_authority("wss://relay.example:443"),
|
|
"relay.example"
|
|
);
|
|
assert_eq!(
|
|
relay_url_authority("ws://relay.example:80"),
|
|
"relay.example"
|
|
);
|
|
assert_eq!(relay_url_authority("wss://relay.example"), "relay.example");
|
|
}
|
|
|
|
#[test]
|
|
fn relay_url_authority_preserves_ipv6_brackets() {
|
|
// `host_str()` strips IPv6 brackets and the port; `relay_url_authority`
|
|
// must keep both so the authority matches `communities.host`.
|
|
assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000");
|
|
}
|
|
|
|
#[test]
|
|
fn relay_url_authority_unparseable_is_empty() {
|
|
// No parseable host → empty authority; callers fail closed.
|
|
assert_eq!(relay_url_authority("not a url"), "");
|
|
assert_eq!(relay_url_authority(""), "");
|
|
}
|
|
}
|