Files
buzz/crates/buzz-auth/src/access.rs
+1 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

252 lines
7.8 KiB
Rust

//! Channel access enforcement.
//!
//! Defines [`ChannelAccessChecker`] so `buzz-auth` can enforce access
//! without depending on `buzz-db` directly.
use std::collections::HashSet;
use std::future::Future;
use buzz_core::TenantContext;
use nostr::PublicKey;
use uuid::Uuid;
use crate::error::AuthError;
use crate::scope::Scope;
/// Async trait for checking channel membership.
///
/// Implemented by the database layer (`buzz-db`) in production. The `buzz-auth`
/// crate defines the trait so it can enforce access rules without a direct dependency
/// on `buzz-db`.
///
/// ## Tenant scoping
///
/// Every method takes `&TenantContext`. Channel UUIDs are not globally unique under
/// multi-tenant — the frozen schema's `channels` PK is `(community_id, id)`, so the
/// same UUID can legitimately exist in two communities. A bare `WHERE id = $1`
/// implementation would be a cross-community existence oracle and could return
/// `true` for a B-community membership when the request bound community is A.
/// Implementations MUST scope every query by `ctx.community()` (S1 cross-community
/// fence at the access layer).
pub trait ChannelAccessChecker: Send + Sync {
/// Return the set of channel UUIDs in `ctx`'s community accessible to `pubkey`.
///
/// Channels in other communities, even with the same UUID, MUST NOT appear.
fn accessible_channel_ids(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> impl Future<Output = Result<HashSet<Uuid>, AuthError>> + Send;
/// Returns `true` if `pubkey` is a member of `(ctx.community, channel_id)`.
///
/// Default implementation calls [`Self::accessible_channel_ids`] and checks
/// membership. Implementations may override this with a more efficient
/// scoped point-lookup query.
fn can_access(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
channel_id: Uuid,
) -> impl Future<Output = Result<bool, AuthError>> + Send {
async move {
let ids = self.accessible_channel_ids(ctx, pubkey).await?;
Ok(ids.contains(&channel_id))
}
}
}
/// Check that `scopes` contains the required scope.
pub fn require_scope(scopes: &[Scope], required: Scope) -> Result<(), AuthError> {
if scopes.contains(&required) {
Ok(())
} else {
Err(AuthError::InsufficientScope {
required: required.as_str().to_string(),
have: scopes.iter().map(|s| s.as_str().to_string()).collect(),
})
}
}
/// Verify read access: scope + membership in `ctx`'s community.
pub async fn check_read_access(
checker: &impl ChannelAccessChecker,
ctx: &TenantContext,
pubkey: &PublicKey,
channel_id: Uuid,
scopes: &[Scope],
) -> Result<(), AuthError> {
require_scope(scopes, Scope::MessagesRead)?;
if checker.can_access(ctx, pubkey, channel_id).await? {
Ok(())
} else {
Err(AuthError::ChannelAccessDenied)
}
}
/// Verify write access: scope + membership in `ctx`'s community.
pub async fn check_write_access(
checker: &impl ChannelAccessChecker,
ctx: &TenantContext,
pubkey: &PublicKey,
channel_id: Uuid,
scopes: &[Scope],
) -> Result<(), AuthError> {
require_scope(scopes, Scope::MessagesWrite)?;
if checker.can_access(ctx, pubkey, channel_id).await? {
Ok(())
} else {
Err(AuthError::ChannelAccessDenied)
}
}
/// In-memory [`ChannelAccessChecker`] for unit tests.
///
/// Membership is keyed on the full `(community_id, pubkey, channel_id)` tuple
/// so the mock can't accidentally model a non-tenant-scoped checker.
#[cfg(any(test, feature = "test-utils"))]
pub struct MockAccessChecker {
allowed: HashSet<(uuid::Uuid, String, Uuid)>,
}
#[cfg(any(test, feature = "test-utils"))]
impl MockAccessChecker {
/// Create an empty checker (all access denied by default).
pub fn new() -> Self {
Self {
allowed: HashSet::new(),
}
}
/// Grant `pubkey` access to `channel_id` inside `ctx`'s community.
pub fn allow(&mut self, ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid) {
self.allowed
.insert((*ctx.community().as_uuid(), pubkey.to_hex(), channel_id));
}
}
#[cfg(any(test, feature = "test-utils"))]
impl Default for MockAccessChecker {
fn default() -> Self {
Self::new()
}
}
#[cfg(any(test, feature = "test-utils"))]
impl ChannelAccessChecker for MockAccessChecker {
async fn accessible_channel_ids(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<HashSet<Uuid>, AuthError> {
let community = *ctx.community().as_uuid();
let hex = pubkey.to_hex();
Ok(self
.allowed
.iter()
.filter(|(c, pk, _)| *c == community && pk == &hex)
.map(|(_, _, id)| *id)
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::CommunityId;
use nostr::Keys;
fn fixture_ctx() -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example")
}
#[tokio::test]
async fn mock_checker_allow_and_deny() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let allowed_ch = Uuid::new_v4();
let denied_ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx, &pk, allowed_ch);
assert!(checker.can_access(&ctx, &pk, allowed_ch).await.unwrap());
assert!(!checker.can_access(&ctx, &pk, denied_ch).await.unwrap());
}
#[tokio::test]
async fn read_access_denied_by_scope() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx, &pk, ch);
assert!(matches!(
check_read_access(&checker, &ctx, &pk, ch, &[]).await,
Err(AuthError::InsufficientScope { .. })
));
}
#[tokio::test]
async fn read_access_denied_by_membership() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let checker = MockAccessChecker::new();
assert!(matches!(
check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]).await,
Err(AuthError::ChannelAccessDenied)
));
}
#[tokio::test]
async fn read_access_granted() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx, &pk, ch);
assert!(
check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead])
.await
.is_ok()
);
}
#[tokio::test]
async fn access_does_not_cross_communities() {
// S1 fence at the access layer: same pubkey, same channel UUID, two
// communities. A grant in A MUST NOT show up under B's TenantContext.
// This bites the existence-oracle direction a bare `WHERE id=$1`
// checker would have left open.
let ctx_a = fixture_ctx();
let ctx_b = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx_a, &pk, ch);
assert!(checker.can_access(&ctx_a, &pk, ch).await.unwrap());
assert!(
!checker.can_access(&ctx_b, &pk, ch).await.unwrap(),
"access in community A must NOT leak into community B for same (pubkey, channel_id)"
);
assert!(checker
.accessible_channel_ids(&ctx_b, &pk)
.await
.unwrap()
.is_empty());
}
}