mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
189 lines
5.4 KiB
Rust
189 lines
5.4 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 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`.
|
|
pub trait ChannelAccessChecker: Send + Sync {
|
|
/// Return the set of channel UUIDs accessible to `pubkey`.
|
|
fn accessible_channel_ids(
|
|
&self,
|
|
pubkey: &PublicKey,
|
|
) -> impl Future<Output = Result<HashSet<Uuid>, AuthError>> + Send;
|
|
|
|
/// Returns `true` if `pubkey` is a member of `channel_id`.
|
|
///
|
|
/// Default implementation calls [`Self::accessible_channel_ids`] and checks membership.
|
|
/// Implementations may override this with a more efficient point-lookup query.
|
|
fn can_access(
|
|
&self,
|
|
pubkey: &PublicKey,
|
|
channel_id: Uuid,
|
|
) -> impl Future<Output = Result<bool, AuthError>> + Send {
|
|
async move {
|
|
let ids = self.accessible_channel_ids(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.
|
|
pub async fn check_read_access(
|
|
checker: &impl ChannelAccessChecker,
|
|
pubkey: &PublicKey,
|
|
channel_id: Uuid,
|
|
scopes: &[Scope],
|
|
) -> Result<(), AuthError> {
|
|
require_scope(scopes, Scope::MessagesRead)?;
|
|
if checker.can_access(pubkey, channel_id).await? {
|
|
Ok(())
|
|
} else {
|
|
Err(AuthError::ChannelAccessDenied)
|
|
}
|
|
}
|
|
|
|
/// Verify write access: scope + membership.
|
|
pub async fn check_write_access(
|
|
checker: &impl ChannelAccessChecker,
|
|
pubkey: &PublicKey,
|
|
channel_id: Uuid,
|
|
scopes: &[Scope],
|
|
) -> Result<(), AuthError> {
|
|
require_scope(scopes, Scope::MessagesWrite)?;
|
|
if checker.can_access(pubkey, channel_id).await? {
|
|
Ok(())
|
|
} else {
|
|
Err(AuthError::ChannelAccessDenied)
|
|
}
|
|
}
|
|
|
|
// ── Test-only mock ───────────────────────────────────────────────────────────
|
|
|
|
/// In-memory [`ChannelAccessChecker`] for unit tests.
|
|
#[cfg(any(test, feature = "test-utils"))]
|
|
pub struct MockAccessChecker {
|
|
allowed: HashSet<(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`.
|
|
pub fn allow(&mut self, pubkey: &PublicKey, channel_id: Uuid) {
|
|
self.allowed.insert((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, pubkey: &PublicKey) -> Result<HashSet<Uuid>, AuthError> {
|
|
let hex = pubkey.to_hex();
|
|
Ok(self
|
|
.allowed
|
|
.iter()
|
|
.filter(|(pk, _)| pk == &hex)
|
|
.map(|(_, id)| *id)
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use nostr::Keys;
|
|
|
|
#[tokio::test]
|
|
async fn mock_checker_allow_and_deny() {
|
|
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(&pk, allowed_ch);
|
|
|
|
assert!(checker.can_access(&pk, allowed_ch).await.unwrap());
|
|
assert!(!checker.can_access(&pk, denied_ch).await.unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_access_denied_by_scope() {
|
|
let keys = Keys::generate();
|
|
let pk = keys.public_key();
|
|
let ch = Uuid::new_v4();
|
|
|
|
let mut checker = MockAccessChecker::new();
|
|
checker.allow(&pk, ch);
|
|
|
|
assert!(matches!(
|
|
check_read_access(&checker, &pk, ch, &[]).await,
|
|
Err(AuthError::InsufficientScope { .. })
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_access_denied_by_membership() {
|
|
let keys = Keys::generate();
|
|
let pk = keys.public_key();
|
|
let ch = Uuid::new_v4();
|
|
let checker = MockAccessChecker::new();
|
|
|
|
assert!(matches!(
|
|
check_read_access(&checker, &pk, ch, &[Scope::MessagesRead]).await,
|
|
Err(AuthError::ChannelAccessDenied)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_access_granted() {
|
|
let keys = Keys::generate();
|
|
let pk = keys.public_key();
|
|
let ch = Uuid::new_v4();
|
|
|
|
let mut checker = MockAccessChecker::new();
|
|
checker.allow(&pk, ch);
|
|
|
|
assert!(check_read_access(&checker, &pk, ch, &[Scope::MessagesRead])
|
|
.await
|
|
.is_ok());
|
|
}
|
|
}
|