fix(relay): scope relay membership to its community

NIP-43 admission confinement (Max review #3 on PR #1321). The
`relay_members` table is keyed `(community_id, pubkey)`, but every DB
access keyed on `pubkey` alone. In closed mode a pubkey admitted to
community A was therefore admitted to community B — the exact M9
mutation #1285 targets.

Request-path scoping:
- thread `CommunityId` through all 9 `buzz-db::relay_members` functions
  and their `Db` wrappers; every query/insert/list/bootstrap now binds
  `community_id` (PK `ON CONFLICT (community_id, pubkey)`, WHERE clauses
  carry community).
- `check_/enforce_relay_membership` take the server-resolved community;
  pass `tenant.community()` at every entrypoint that already binds a
  tenant: bridge (events/query/count), media upload, git transport,
  audio handle, WS auth (`conn.tenant`), mesh `require_mesh_member`
  (connect + status), relay-admin events, leave-request ingest,
  identity-archive consent, NIP-43 list publish, and the buzz-admin CLI
  (via `resolve_admin_tenant`).

Startup seeding (the bootstrap half of the same fix): membership
backfill and owner bootstrap previously ran with no community. They now
run against the deployment's own community, seeded via
`ensure_configured_community` under the *same* normalized host that live
request resolution derives (`relay_url_authority` → `normalize_host`, now
`pub`), so the bootstrapped owner lands in exactly the community requests
for this host resolve to. An unparseable `relay_url` fails fast when
membership is enforced rather than seeding an unreachable empty-host
community.

Regression (Postgres-backed, `#[ignore]`):
- `membership_is_confined_to_its_community`: A admits a pubkey, B does
  not — `is_/get_/list_relay_members` confine it to A.
- `owner_bootstrap_is_confined_to_its_community`: owner bootstrapped in A
  is not a member of B.

cargo test -p buzz-db -p buzz-relay -p buzz-admin green; both new tests
pass against live Postgres; clippy clean on all three crates.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-27 14:43:51 -04:00
co-authored by Tyler Longwell
parent 6846a8f10c
commit 1151eca83d
16 changed files with 426 additions and 128 deletions
+12 -7
View File
@@ -145,7 +145,11 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
let (db, pubsub, relay_keypair) = connect_member_services().await?;
match db.add_relay_member(&pubkey_hex, &role, None).await {
let tenant = resolve_admin_tenant(&db).await?;
match db
.add_relay_member(tenant.community(), &pubkey_hex, &role, None)
.await
{
Ok(true) => println!("added {pubkey_hex} as {role}"),
Ok(false) => println!("already a member: {pubkey_hex} (no change)"),
Err(e) => {
@@ -154,7 +158,6 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
}
}
let tenant = resolve_admin_tenant(&db).await?;
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
eprintln!("warning: member added to DB but list publish failed: {e}");
}
@@ -180,11 +183,13 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
let (db, pubsub, relay_keypair) = connect_member_services().await?;
let tenant = resolve_admin_tenant(&db).await?;
use buzz_db::relay_members::RemoveResult;
let result = if let Some(ref role) = role_filter {
db.remove_relay_member_if_role(&pubkey_hex, role).await
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
.await
} else {
db.remove_relay_member(&pubkey_hex).await
db.remove_relay_member(tenant.community(), &pubkey_hex).await
};
match result {
@@ -211,7 +216,6 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
}
}
let tenant = resolve_admin_tenant(&db).await?;
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
eprintln!("warning: member removed from DB but list publish failed: {e}");
}
@@ -221,7 +225,8 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
async fn cmd_list_members() -> Result<i32> {
let db = connect_db().await?;
let members = db.list_relay_members().await?;
let tenant = resolve_admin_tenant(&db).await?;
let members = db.list_relay_members(tenant.community()).await?;
if members.is_empty() {
println!("(no relay members)");
@@ -303,7 +308,7 @@ async fn publish_membership_list_with_bump(
None => now,
};
let members = db.list_relay_members().await?;
let members = db.list_relay_members(tenant.community()).await?;
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
+41 -26
View File
@@ -1799,71 +1799,86 @@ impl Db {
Ok(out)
}
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
pub async fn is_relay_member(&self, pubkey: &str) -> Result<bool> {
relay_members::is_relay_member(&self.pool, pubkey).await
/// Returns `true` if `pubkey` (64-char hex) is a member of `community`.
pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result<bool> {
relay_members::is_relay_member(&self.pool, community, pubkey).await
}
/// Returns the relay member record for `pubkey`, or `None` if not found.
/// Returns the relay member record for `pubkey` in `community`, or `None` if not found.
pub async fn get_relay_member(
&self,
community: CommunityId,
pubkey: &str,
) -> Result<Option<relay_members::RelayMember>> {
relay_members::get_relay_member(&self.pool, pubkey).await
relay_members::get_relay_member(&self.pool, community, pubkey).await
}
/// Returns all relay members ordered by `created_at` ascending.
pub async fn list_relay_members(&self) -> Result<Vec<relay_members::RelayMember>> {
relay_members::list_relay_members(&self.pool).await
/// Returns all relay members of `community` ordered by `created_at` ascending.
pub async fn list_relay_members(
&self,
community: CommunityId,
) -> Result<Vec<relay_members::RelayMember>> {
relay_members::list_relay_members(&self.pool, community).await
}
/// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent).
/// Adds a new relay member.
/// Adds a new relay member to `community`.
///
/// Returns `true` if the row was actually inserted, `false` if the pubkey
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
/// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`).
pub async fn add_relay_member(
&self,
community: CommunityId,
pubkey: &str,
role: &str,
added_by: Option<&str>,
) -> Result<bool> {
relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await
relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await
}
/// Removes a relay member atomically, refusing to delete the owner.
pub async fn remove_relay_member(&self, pubkey: &str) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member(&self.pool, pubkey).await
/// Removes a relay member from `community` atomically, refusing to delete the owner.
pub async fn remove_relay_member(
&self,
community: CommunityId,
pubkey: &str,
) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member(&self.pool, community, pubkey).await
}
/// Removes a relay member only if their current role matches `expected_role`.
/// Removes a relay member from `community` only if their current role matches `expected_role`.
///
/// Atomic conditional delete — eliminates the TOCTOU race between a
/// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`].
pub async fn remove_relay_member_if_role(
&self,
community: CommunityId,
pubkey: &str,
expected_role: &str,
) -> Result<relay_members::RemoveResult> {
relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await
relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role)
.await
}
/// Updates the role of an existing relay member. Returns `true` if updated.
pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result<bool> {
relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await
/// Updates the role of an existing relay member in `community`. Returns `true` if updated.
pub async fn update_relay_member_role(
&self,
community: CommunityId,
pubkey: &str,
new_role: &str,
) -> Result<bool> {
relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await
}
/// Ensures the owner pubkey exists with role `"owner"`. Called at startup.
pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> {
relay_members::bootstrap_owner(&self.pool, owner_pubkey).await
/// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup.
pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> {
relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await
}
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
/// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`.
///
/// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows
/// inserted, or 0 if the `pubkey_allowlist` table doesn't exist.
pub async fn backfill_from_allowlist(&self) -> Result<u64> {
relay_members::backfill_from_allowlist(&self.pool).await
pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result<u64> {
relay_members::backfill_from_allowlist(&self.pool, community).await
}
/// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`.
+236 -53
View File
@@ -1,12 +1,16 @@
//! Relay-level membership persistence (NIP-43).
//!
//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata.
//! All pubkey values are 64-char lowercase hex strings.
//! The `relay_members` table is community-scoped: its primary key is
//! `(community_id, pubkey)`. Every read, write, and list is bound to a single
//! `community_id` so that admitting a pubkey to community A never admits it to
//! community B (NIP-43 admission confinement). `pubkey` values are 64-char
//! lowercase hex strings.
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row as _};
use crate::error::Result;
use crate::CommunityId;
/// A single relay member record.
#[derive(Debug, Clone)]
@@ -23,21 +27,31 @@ pub struct RelayMember {
pub updated_at: DateTime<Utc>,
}
/// Returns `true` if `pubkey` (64-char hex) is in the relay member list.
pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
/// Returns `true` if `pubkey` (64-char hex) is a member of `community`.
pub async fn is_relay_member(
pool: &PgPool,
community: CommunityId,
pubkey: &str,
) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
.bind(community.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Returns the relay member record for `pubkey`, or `None` if not found.
pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<RelayMember>> {
/// Returns the relay member record for `pubkey` in `community`, or `None`.
pub async fn get_relay_member(
pool: &PgPool,
community: CommunityId,
pubkey: &str,
) -> Result<Option<RelayMember>> {
let row = sqlx::query(
"SELECT pubkey, role, added_by, created_at, updated_at \
FROM relay_members WHERE pubkey = $1",
FROM relay_members WHERE community_id = $1 AND pubkey = $2",
)
.bind(community.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.await?;
@@ -55,12 +69,16 @@ pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result<Option<Rela
.map_err(crate::error::DbError::from)
}
/// Returns all relay members ordered by `created_at` ascending.
pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
/// Returns all relay members of `community` ordered by `created_at` ascending.
pub async fn list_relay_members(
pool: &PgPool,
community: CommunityId,
) -> Result<Vec<RelayMember>> {
let rows = sqlx::query(
"SELECT pubkey, role, added_by, created_at, updated_at \
FROM relay_members ORDER BY created_at ASC",
FROM relay_members WHERE community_id = $1 ORDER BY created_at ASC",
)
.bind(community.as_uuid())
.fetch_all(pool)
.await?;
@@ -78,20 +96,23 @@ pub async fn list_relay_members(pool: &PgPool) -> Result<Vec<RelayMember>> {
.map_err(crate::error::DbError::from)
}
/// Adds a new relay member.
/// Adds a new relay member to `community`.
///
/// Returns `true` if the row was actually inserted, `false` if the pubkey
/// already existed (idempotent — `ON CONFLICT DO NOTHING`).
/// already existed in this community (idempotent — `ON CONFLICT DO NOTHING` on
/// the `(community_id, pubkey)` primary key).
pub async fn add_relay_member(
pool: &PgPool,
community: CommunityId,
pubkey: &str,
role: &str,
added_by: Option<&str>,
) -> Result<bool> {
let result = sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by) \
VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING",
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING",
)
.bind(community.as_uuid())
.bind(pubkey)
.bind(role)
.bind(added_by)
@@ -118,11 +139,19 @@ pub enum RemoveResult {
/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the
/// owner-protection check and the deletion are one atomic operation —
/// no TOCTOU race between a separate read and delete.
pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveResult> {
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'")
.bind(pubkey)
.execute(pool)
.await?;
pub async fn remove_relay_member(
pool: &PgPool,
community: CommunityId,
pubkey: &str,
) -> Result<RemoveResult> {
let result = sqlx::query(
"DELETE FROM relay_members \
WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'",
)
.bind(community.as_uuid())
.bind(pubkey)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
return Ok(RemoveResult::Removed);
@@ -130,7 +159,8 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveRe
// rows_affected == 0: either not found or is owner. One cheap read to
// distinguish the two cases so callers can return the right error message.
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1")
let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
.bind(community.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.await?;
@@ -157,14 +187,18 @@ pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result<RemoveRe
/// this delete).
pub async fn remove_relay_member_if_role(
pool: &PgPool,
community: CommunityId,
pubkey: &str,
expected_role: &str,
) -> Result<RemoveResult> {
let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2")
.bind(pubkey)
.bind(expected_role)
.execute(pool)
.await?;
let result = sqlx::query(
"DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3",
)
.bind(community.as_uuid())
.bind(pubkey)
.bind(expected_role)
.execute(pool)
.await?;
if result.rows_affected() > 0 {
return Ok(RemoveResult::Removed);
@@ -172,7 +206,8 @@ pub async fn remove_relay_member_if_role(
// rows_affected == 0: either not found or role changed. One cheap read to
// distinguish the cases so callers can return the right error message.
let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1")
let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2")
.bind(community.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.await?;
@@ -193,42 +228,58 @@ pub async fn remove_relay_member_if_role(
}
}
/// Updates the role of an existing relay member. Returns `true` if updated.
pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result<bool> {
/// Updates the role of an existing relay member in `community`. Returns `true`
/// if updated.
pub async fn update_relay_member_role(
pool: &PgPool,
community: CommunityId,
pubkey: &str,
new_role: &str,
) -> Result<bool> {
let result = sqlx::query(
"UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'",
"UPDATE relay_members SET role = $1, updated_at = now() \
WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'",
)
.bind(new_role)
.bind(community.as_uuid())
.bind(pubkey)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes
/// any other owners to `"admin"`. This handles owner rotation: if
/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted.
/// Ensures the configured owner pubkey holds the `"owner"` role *in
/// `community`*, and demotes any other owners in that community to `"admin"`.
/// This handles owner rotation: if `RELAY_OWNER_PUBKEY` changes, the old owner
/// is automatically demoted. Scoped to one community — an owner of community A
/// is never bootstrapped into community B.
///
/// Runs in a single transaction. Safe to call at every startup — idempotent.
pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
pub async fn bootstrap_owner(
pool: &PgPool,
community: CommunityId,
owner_pubkey: &str,
) -> Result<()> {
let pubkey = owner_pubkey.to_ascii_lowercase();
let mut tx = pool.begin().await?;
// 1. Upsert the configured owner.
// 1. Upsert the configured owner for this community.
sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by) \
VALUES ($1, 'owner', NULL) \
ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
VALUES ($1, $2, 'owner', NULL) \
ON CONFLICT (community_id, pubkey) DO UPDATE SET role = 'owner', updated_at = now()",
)
.bind(community.as_uuid())
.bind(&pubkey)
.execute(&mut *tx)
.await?;
// 2. Demote any other owners to admin.
// 2. Demote any other owners in this community to admin.
sqlx::query(
"UPDATE relay_members SET role = 'admin', updated_at = now() \
WHERE role = 'owner' AND pubkey <> $1",
WHERE community_id = $1 AND role = 'owner' AND pubkey <> $2",
)
.bind(community.as_uuid())
.bind(&pubkey)
.execute(&mut *tx)
.await?;
@@ -237,16 +288,18 @@ pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> {
Ok(())
}
/// Migrates existing `pubkey_allowlist` entries into `relay_members`.
/// Migrates existing `pubkey_allowlist` entries into `relay_members` for
/// `community` (the deployment's default community).
///
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members.
/// Returns the number of rows inserted, or 0 if:
/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members of
/// `community`. Returns the number of rows inserted, or 0 if:
/// - the `pubkey_allowlist` table doesn't exist, or
/// - `relay_members` already has rows (migration ran in a prior startup).
/// - `relay_members` already has rows for this community (migration ran in a
/// prior startup).
///
/// The empty-table guard prevents re-adding members that were intentionally
/// removed by an admin after the initial backfill.
pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result<u64> {
// Check if pubkey_allowlist table exists.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables \
@@ -259,25 +312,155 @@ pub async fn backfill_from_allowlist(pool: &PgPool) -> Result<u64> {
return Ok(0);
}
// Only backfill if relay_members is empty — once the table has rows
// (from a previous backfill or manual admin commands), we must not
// Only backfill if this community's relay_members is empty — once it has
// rows (from a previous backfill or manual admin commands), we must not
// re-add members that were intentionally removed.
let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)")
.fetch_one(pool)
.await?;
let has_members: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)",
)
.bind(community.as_uuid())
.fetch_one(pool)
.await?;
if has_members {
return Ok(0);
}
let result = sqlx::query(
"INSERT INTO relay_members (pubkey, role, added_by, created_at) \
SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \
"INSERT INTO relay_members (community_id, pubkey, role, added_by, created_at) \
SELECT $1, encode(pubkey, 'hex'), 'member', NULL, added_at \
FROM pubkey_allowlist \
ON CONFLICT (pubkey) DO NOTHING",
ON CONFLICT (community_id, pubkey) DO NOTHING",
)
.bind(community.as_uuid())
.execute(pool)
.await?;
Ok(result.rows_affected())
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
async fn setup_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
PgPool::connect(&database_url)
.await
.expect("connect to test DB")
}
async fn make_test_community(pool: &PgPool) -> CommunityId {
let id = Uuid::new_v4();
let host = format!("relay-members-test-{}.example", id.simple());
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(id)
.bind(host)
.execute(pool)
.await
.expect("insert test community");
CommunityId::from_uuid(id)
}
/// NIP-43 admission confinement: a pubkey admitted to community A is *not*
/// admitted to community B. This is the exact mutation #1285 targets — a
/// `WHERE pubkey = $1` membership check (no community predicate) would let an
/// A-member authenticate against B. We add the pubkey only to A and assert
/// every read path (`is_relay_member`, `get_relay_member`, `list_relay_members`)
/// confines it to A.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn membership_is_confined_to_its_community() {
let pool = setup_pool().await;
let community_a = make_test_community(&pool).await;
let community_b = make_test_community(&pool).await;
// 64-char lowercase hex, unique per run so reruns don't collide.
let pubkey = format!("{:064x}", Uuid::new_v4().as_u128());
let inserted = add_relay_member(&pool, community_a, &pubkey, "member", None)
.await
.expect("add member to community A");
assert!(inserted, "first insert into A should report inserted");
// is_relay_member: member of A, NOT of B.
assert!(
is_relay_member(&pool, community_a, &pubkey)
.await
.expect("is_relay_member A"),
"pubkey must be a member of community A"
);
assert!(
!is_relay_member(&pool, community_b, &pubkey)
.await
.expect("is_relay_member B"),
"pubkey admitted to A must NOT be a member of B (admission confinement)"
);
// get_relay_member (used by the NIP-OA owner check + admin role lookups):
// resolves in A, absent in B.
assert!(
get_relay_member(&pool, community_a, &pubkey)
.await
.expect("get_relay_member A")
.is_some(),
"get_relay_member must resolve in community A"
);
assert!(
get_relay_member(&pool, community_b, &pubkey)
.await
.expect("get_relay_member B")
.is_none(),
"get_relay_member must not resolve the A pubkey in community B"
);
// list_relay_members: B's list never contains A's member.
let list_a = list_relay_members(&pool, community_a)
.await
.expect("list A");
assert!(
list_a.iter().any(|m| m.pubkey == pubkey),
"community A list must contain the admitted pubkey"
);
let list_b = list_relay_members(&pool, community_b)
.await
.expect("list B");
assert!(
list_b.iter().all(|m| m.pubkey != pubkey),
"community B list must not contain A's member"
);
}
/// Owner bootstrap is community-scoped: bootstrapping the owner in A does not
/// make that pubkey an owner (or member) of B. Guards against a global
/// `INSERT ... (pubkey, role)` bootstrap leaking the owner across tenants.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn owner_bootstrap_is_confined_to_its_community() {
let pool = setup_pool().await;
let community_a = make_test_community(&pool).await;
let community_b = make_test_community(&pool).await;
let owner = format!("{:064x}", Uuid::new_v4().as_u128());
bootstrap_owner(&pool, community_a, &owner)
.await
.expect("bootstrap owner in A");
let in_a = get_relay_member(&pool, community_a, &owner)
.await
.expect("get owner A")
.expect("owner exists in A");
assert_eq!(in_a.role, "owner", "bootstrapped pubkey must be owner in A");
assert!(
!is_relay_member(&pool, community_b, &owner)
.await
.expect("is_relay_member B"),
"owner bootstrapped in A must NOT be a member of B"
);
}
}
+21 -3
View File
@@ -261,7 +261,13 @@ pub async fn submit_event(
// Enforce relay membership (with NIP-OA fallback via x-auth-tag header).
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.await?;
let event: nostr::Event = serde_json::from_slice(&body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?;
@@ -349,7 +355,13 @@ pub async fn query_events(
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.await?;
// Two-pass parse: preserve raw JSON for custom extension fields (before_id,
// depth_limit, feed_types) that nostr::Filter silently drops.
@@ -624,7 +636,13 @@ pub async fn count_events(
let pubkey_bytes = pubkey.to_bytes().to_vec();
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
super::relay_members::enforce_relay_membership(
&state,
tenant.community(),
&pubkey_bytes,
auth_tag,
)
.await?;
let filters: Vec<nostr::Filter> = serde_json::from_slice(&body)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
+8 -3
View File
@@ -182,9 +182,14 @@ impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
.headers
.get("x-auth-tag")
.and_then(|v| v.to_str().ok());
if crate::api::relay_members::enforce_relay_membership(state, pubkey.as_bytes(), auth_tag)
.await
.is_err()
if crate::api::relay_members::enforce_relay_membership(
state,
tenant.community(),
pubkey.as_bytes(),
auth_tag,
)
.await
.is_err()
{
warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied");
return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response());
+1
View File
@@ -112,6 +112,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
crate::api::relay_members::enforce_relay_membership(
state,
tenant.community(),
auth_event.pubkey.as_bytes(),
auth_tag,
)
+13 -4
View File
@@ -32,6 +32,7 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
/// `git/transport.rs`, and `audio/handler.rs`.
pub mod relay_members {
use axum::{http::StatusCode, response::Json};
use buzz_core::tenant::CommunityId;
use tracing::{debug, info};
use crate::state::AppState;
@@ -50,8 +51,12 @@ pub mod relay_members {
}
/// Check relay membership without committing to an HTTP response shape.
///
/// `community` is the server-resolved tenant of the request; membership is
/// scoped to it so admitting a pubkey to community A never admits it to B.
pub async fn check_relay_membership(
state: &AppState,
community: CommunityId,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<MembershipDecision, String> {
@@ -62,7 +67,7 @@ pub mod relay_members {
let pubkey_hex = hex::encode(pubkey_bytes);
let is_member = state
.db
.is_relay_member(&pubkey_hex)
.is_relay_member(community, &pubkey_hex)
.await
.map_err(|e| format!("relay membership check failed: {e}"))?;
if is_member {
@@ -77,8 +82,11 @@ pub mod relay_members {
match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
Ok(owner_pubkey) => {
let owner_hex = owner_pubkey.to_hex();
let owner_is_member =
state.db.is_relay_member(&owner_hex).await.map_err(|e| {
let owner_is_member = state
.db
.is_relay_member(community, &owner_hex)
.await
.map_err(|e| {
format!("relay membership check (owner) failed: {e}")
})?;
if owner_is_member {
@@ -113,10 +121,11 @@ pub mod relay_members {
/// no NIP-OA tag is present/applicable (open relay without auth tag).
pub async fn enforce_relay_membership(
state: &AppState,
community: CommunityId,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
match check_relay_membership(state, pubkey_bytes, auth_tag_header).await {
match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await {
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None),
Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)),
Ok(MembershipDecision::Denied) => Err((
+1
View File
@@ -182,6 +182,7 @@ async fn handle_audio_connection(
if crate::api::relay_members::enforce_relay_membership(
&state,
tenant.community(),
pubkey.as_bytes(),
auth_tag_json.as_deref(),
)
+1
View File
@@ -113,6 +113,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
// Relay membership gate — uses the shared helper with NIP-OA fallback.
let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership(
&state,
conn.tenant.community(),
pubkey.as_bytes(),
auth_tag_json.as_deref(),
)
@@ -238,7 +238,7 @@ async fn determine_consent_path(
let actor_member = state
.db
.get_relay_member(actor_hex)
.get_relay_member(community_id, actor_hex)
.await
.map_err(|e| format!("database error: {e}"))?;
let actor_role = actor_member.as_ref().map(|m| m.role.as_str()).unwrap_or("");
+1 -1
View File
@@ -1478,7 +1478,7 @@ async fn ingest_event_inner(
// remove_relay_member handles both the NotFound and IsOwner cases atomically.
let remove_result = state
.db
.remove_relay_member(&sender_hex)
.remove_relay_member(tenant.community(), &sender_hex)
.await
.map_err(|e| IngestError::Internal(format!("database error: {e}")))?;
@@ -26,7 +26,7 @@ use buzz_core::event::StoredEvent;
use buzz_core::kind::{
event_kind_u32, KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT,
};
use buzz_core::tenant::TenantContext;
use buzz_core::tenant::{CommunityId, TenantContext};
use nostr::{EventBuilder, Kind, Tag};
use crate::api::relay_members::{check_relay_membership, MembershipDecision};
@@ -234,11 +234,11 @@ pub async fn handle_connect_request(
// BUZZ_ALLOW_NIP_OA_AUTH is on; v1 mesh excludes delegated identities, so we
// re-check the requester here with no auth tag (which makes ViaOwner
// unreachable — only Member/OpenRelay/Denied) to match the target check.
require_mesh_member(state, requester_pubkey_hex)
require_mesh_member(state, tenant.community(), requester_pubkey_hex)
.await
.map_err(|_| "restricted: delegated identities cannot initiate mesh in v1".to_string())?;
require_mesh_member(state, &target_hex)
require_mesh_member(state, tenant.community(), &target_hex)
.await
.map_err(|_| "restricted: target is not a relay member".to_string())?;
@@ -274,9 +274,13 @@ pub async fn handle_connect_request(
/// `None` auth_tag → ViaOwner is unreachable, so only Member/OpenRelay admit;
/// everything else (Denied, ViaOwner-if-it-somehow-appeared, or a check error)
/// FAILS CLOSED. Used symmetrically for requester and target.
async fn require_mesh_member(state: &Arc<AppState>, pubkey_hex: &str) -> Result<(), ()> {
async fn require_mesh_member(
state: &Arc<AppState>,
community: CommunityId,
pubkey_hex: &str,
) -> Result<(), ()> {
let bytes = hex::decode(pubkey_hex).map_err(|_| ())?;
match check_relay_membership(state, &bytes, None).await {
match check_relay_membership(state, community, &bytes, None).await {
Ok(d) if membership_admits_mesh(&d) => Ok(()),
Ok(_) => Err(()),
Err(e) => {
@@ -355,7 +359,7 @@ pub async fn handle_status_report(
// pubkey that the connect path (which denies ViaOwner) then refuses — broken
// discovery. So gate the reporter the same way: direct members only, fail
// closed. Keeps all three desktop-facing mesh kinds consistent on delegation.
require_mesh_member(state, reporter_pubkey_hex)
require_mesh_member(state, tenant.community(), reporter_pubkey_hex)
.await
.map_err(|_| {
"restricted: delegated identities cannot report mesh status in v1".to_string()
@@ -94,7 +94,7 @@ pub async fn handle_relay_admin_event(
let sender_member = state
.db
.get_relay_member(&sender_hex)
.get_relay_member(tenant.community(), &sender_hex)
.await
.map_err(|e| format!("database error: {e}"))?;
@@ -130,7 +130,7 @@ pub async fn handle_relay_admin_event(
// to change an existing member's role.
let was_inserted = state
.db
.add_relay_member(&target_hex, &role, Some(&sender_hex))
.add_relay_member(tenant.community(), &target_hex, &role, Some(&sender_hex))
.await
.map_err(|e| format!("database error: {e}"))?;
@@ -174,14 +174,14 @@ pub async fn handle_relay_admin_event(
let remove_result = if sender_role == "admin" {
state
.db
.remove_relay_member_if_role(&target_hex, "member")
.remove_relay_member_if_role(tenant.community(), &target_hex, "member")
.await
.map_err(|e| format!("database error: {e}"))?
} else {
// Owner path — atomic delete that refuses to remove other owners.
state
.db
.remove_relay_member(&target_hex)
.remove_relay_member(tenant.community(), &target_hex)
.await
.map_err(|e| format!("database error: {e}"))?
};
@@ -240,7 +240,7 @@ pub async fn handle_relay_admin_event(
let updated = state
.db
.update_relay_member_role(&target_hex, &new_role)
.update_relay_member_role(tenant.community(), &target_hex, &new_role)
.await
.map_err(|e| format!("database error: {e}"))?;
@@ -248,7 +248,7 @@ pub async fn handle_relay_admin_event(
// Distinguish "owner (protected)" from "doesn't exist"
let exists = state
.db
.get_relay_member(&target_hex)
.get_relay_member(tenant.community(), &target_hex)
.await
.map_err(|e| format!("database error: {e}"))?;
return Err(if exists.is_some() {
@@ -2336,7 +2336,7 @@ pub async fn publish_nip43_membership_list(
tenant: &TenantContext,
state: &Arc<AppState>,
) -> anyhow::Result<()> {
let members = state.db.list_relay_members().await?;
let members = state.db.list_relay_members(tenant.community()).await?;
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
+66 -16
View File
@@ -104,30 +104,80 @@ async fn main() -> anyhow::Result<()> {
));
}
// NIP-43 / multi-tenant: seed the deployment's *own* community before any
// membership backfill or owner bootstrap, so those writes are scoped to a
// real `(community_id, pubkey)` and not a global pubkey. The host is derived
// from `relay_url` with the *same* normalization request resolution uses
// (`relay_url_authority` → `normalize_host`), so the bootstrapped owner lands
// in exactly the community that live requests for this host will resolve to.
//
// `ensure_configured_community` is idempotent (`ON CONFLICT (host)`), so this
// is safe to run every startup. An empty authority (unparseable `relay_url`)
// is a misconfiguration — fail fast when membership is enforced rather than
// seeding an empty-host community that no request can ever resolve to.
let deployment_community = {
let host = buzz_relay::tenant::relay_url_authority(&config.relay_url);
if host.is_empty() {
if config.require_relay_membership {
return Err(anyhow::anyhow!(
"Cannot derive a community host from BUZZ_RELAY_URL ({:?}); a resolvable host is required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true",
config.relay_url
));
}
error!(
relay_url = %config.relay_url,
"Could not derive a community host from relay_url; skipping membership backfill/bootstrap (non-fatal, membership not required)"
);
None
} else {
match db.ensure_configured_community(&host).await {
Ok(record) => {
info!(host = %record.host, community = %record.id, "Deployment community ensured");
Some(record.id)
}
Err(e) => {
if config.require_relay_membership {
error!("Fatal: failed to ensure deployment community with membership enforcement enabled: {e}");
return Err(anyhow::anyhow!(
"Failed to ensure deployment community (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
));
}
error!("Failed to ensure deployment community (non-fatal, membership not required): {e}");
None
}
}
}
};
// NIP-43: migrate any existing pubkey_allowlist entries to relay_members.
// Idempotent — safe to run every startup. Must run before bootstrap_owner
// so that existing allowlist users become relay members before the owner
// is promoted (otherwise enabling membership locks everyone out).
match db.backfill_from_allowlist().await {
Ok(0) => {}
Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"),
Err(e) => {
if config.require_relay_membership {
error!(
"Fatal: failed to backfill allowlist with membership enforcement enabled: {e}"
);
return Err(anyhow::anyhow!(
"Failed to backfill pubkey_allowlist (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
));
} else {
error!("Failed to backfill pubkey_allowlist (non-fatal): {e}");
if let Some(community) = deployment_community {
match db.backfill_from_allowlist(community).await {
Ok(0) => {}
Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"),
Err(e) => {
if config.require_relay_membership {
error!(
"Fatal: failed to backfill allowlist with membership enforcement enabled: {e}"
);
return Err(anyhow::anyhow!(
"Failed to backfill pubkey_allowlist (required when BUZZ_REQUIRE_RELAY_MEMBERSHIP=true): {e}"
));
} else {
error!("Failed to backfill pubkey_allowlist (non-fatal): {e}");
}
}
}
}
// NIP-43: ensure the configured relay owner always holds the owner role.
if let Some(ref owner_pubkey) = config.relay_owner_pubkey {
match db.bootstrap_owner(owner_pubkey).await {
// NIP-43: ensure the configured relay owner always holds the owner role
// within the deployment community.
if let (Some(community), Some(owner_pubkey)) =
(deployment_community, config.relay_owner_pubkey.as_ref())
{
match db.bootstrap_owner(community, owner_pubkey).await {
Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"),
Err(e) => {
if config.require_relay_membership {
+7 -1
View File
@@ -111,7 +111,13 @@ pub async fn bind_deployment_community<R: HostResolver>(
/// 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.
fn relay_url_authority(relay_url: &str) -> String {
///
/// `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. Returns
/// the empty string when `relay_url` has no parseable host.
pub fn relay_url_authority(relay_url: &str) -> String {
let Ok(url) = url::Url::parse(relay_url) else {
return String::new();
};