mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
+1








14fba21e57
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>
250 lines
8.6 KiB
Rust
250 lines
8.6 KiB
Rust
//! Authorization scopes.
|
|
//!
|
|
//! Scopes control what operations an authenticated connection may perform.
|
|
//! In pure Nostr mode, all NIP-42 authenticated connections receive the full
|
|
//! scope set; per-channel access is enforced by NIP-29 membership checks.
|
|
|
|
use std::fmt;
|
|
use std::str::FromStr;
|
|
|
|
/// An authorization scope granted to an authenticated connection or API token.
|
|
///
|
|
/// Scopes are stored as `TEXT[]` in the database so new variants can be added
|
|
/// without schema migrations. Unknown scope strings are preserved via [`Scope::Unknown`]
|
|
/// to allow forward-compatibility with future scope additions.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum Scope {
|
|
/// Read messages from channels the user is a member of.
|
|
MessagesRead,
|
|
/// Send messages to channels the user is a member of.
|
|
MessagesWrite,
|
|
/// List and read channel metadata.
|
|
ChannelsRead,
|
|
/// Create and update channels.
|
|
ChannelsWrite,
|
|
/// Administrative channel operations (e.g. delete, force-remove members).
|
|
AdminChannels,
|
|
/// Read user profile information.
|
|
UsersRead,
|
|
/// Update user profile information.
|
|
UsersWrite,
|
|
/// Administrative user operations (e.g. suspend, impersonate).
|
|
AdminUsers,
|
|
/// Read background job status.
|
|
JobsRead,
|
|
/// Submit and cancel background jobs.
|
|
JobsWrite,
|
|
/// Read subscription/plan information.
|
|
SubscriptionsRead,
|
|
/// Modify subscription/plan information.
|
|
SubscriptionsWrite,
|
|
/// Download files and attachments.
|
|
FilesRead,
|
|
/// Upload files and attachments.
|
|
FilesWrite,
|
|
/// Clone git repositories.
|
|
///
|
|
/// Reserved for future use. Not currently enforced by git HTTP routes —
|
|
/// those use NIP-98 auth directly. Will be enforced when collaborator
|
|
/// access (read-only, maintainer) is added in v2.
|
|
ReposRead,
|
|
/// Push to git repositories and create repos (kind:30617).
|
|
///
|
|
/// Enforced for kind:30617/30618 events via WebSocket ingest, but NOT
|
|
/// enforced by git HTTP push routes (which use NIP-98 + owner check).
|
|
/// Full enforcement deferred to v2 collaborator model.
|
|
ReposWrite,
|
|
/// A scope string not recognised by this version of the relay.
|
|
///
|
|
/// Preserved as-is to allow forward-compatibility with future scope additions.
|
|
Unknown(String),
|
|
}
|
|
|
|
impl Scope {
|
|
/// Return a `Vec` containing every known scope variant.
|
|
///
|
|
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
|
|
/// auth grants unrestricted access — there is no token to derive scopes from.
|
|
pub fn all_known() -> Vec<Scope> {
|
|
vec![
|
|
Self::MessagesRead,
|
|
Self::MessagesWrite,
|
|
Self::ChannelsRead,
|
|
Self::ChannelsWrite,
|
|
Self::AdminChannels,
|
|
Self::UsersRead,
|
|
Self::UsersWrite,
|
|
Self::AdminUsers,
|
|
Self::JobsRead,
|
|
Self::JobsWrite,
|
|
Self::SubscriptionsRead,
|
|
Self::SubscriptionsWrite,
|
|
Self::FilesRead,
|
|
Self::FilesWrite,
|
|
Self::ReposRead,
|
|
Self::ReposWrite,
|
|
]
|
|
}
|
|
|
|
/// Return a `Vec` containing every known scope variant except admin scopes.
|
|
///
|
|
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header auth grants
|
|
/// access without a real token. Admin operations (`AdminChannels`, `AdminUsers`) require
|
|
/// a real token even in dev mode, so they are excluded here.
|
|
pub fn all_non_admin() -> Vec<Scope> {
|
|
vec![
|
|
Self::MessagesRead,
|
|
Self::MessagesWrite,
|
|
Self::ChannelsRead,
|
|
Self::ChannelsWrite,
|
|
Self::UsersRead,
|
|
Self::UsersWrite,
|
|
Self::JobsRead,
|
|
Self::JobsWrite,
|
|
Self::SubscriptionsRead,
|
|
Self::SubscriptionsWrite,
|
|
Self::FilesRead,
|
|
Self::FilesWrite,
|
|
Self::ReposRead,
|
|
Self::ReposWrite,
|
|
]
|
|
}
|
|
|
|
/// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
|
|
pub fn as_str(&self) -> &str {
|
|
match self {
|
|
Self::MessagesRead => "messages:read",
|
|
Self::MessagesWrite => "messages:write",
|
|
Self::ChannelsRead => "channels:read",
|
|
Self::ChannelsWrite => "channels:write",
|
|
Self::AdminChannels => "admin:channels",
|
|
Self::UsersRead => "users:read",
|
|
Self::UsersWrite => "users:write",
|
|
Self::AdminUsers => "admin:users",
|
|
Self::JobsRead => "jobs:read",
|
|
Self::JobsWrite => "jobs:write",
|
|
Self::SubscriptionsRead => "subscriptions:read",
|
|
Self::SubscriptionsWrite => "subscriptions:write",
|
|
Self::FilesRead => "files:read",
|
|
Self::FilesWrite => "files:write",
|
|
Self::ReposRead => "repos:read",
|
|
Self::ReposWrite => "repos:write",
|
|
Self::Unknown(s) => s.as_str(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Scope {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
impl FromStr for Scope {
|
|
type Err = std::convert::Infallible;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
Ok(match s {
|
|
"messages:read" => Self::MessagesRead,
|
|
"messages:write" => Self::MessagesWrite,
|
|
"channels:read" => Self::ChannelsRead,
|
|
"channels:write" => Self::ChannelsWrite,
|
|
"admin:channels" => Self::AdminChannels,
|
|
"users:read" => Self::UsersRead,
|
|
"users:write" => Self::UsersWrite,
|
|
"admin:users" => Self::AdminUsers,
|
|
"jobs:read" => Self::JobsRead,
|
|
"jobs:write" => Self::JobsWrite,
|
|
"subscriptions:read" => Self::SubscriptionsRead,
|
|
"subscriptions:write" => Self::SubscriptionsWrite,
|
|
"files:read" => Self::FilesRead,
|
|
"files:write" => Self::FilesWrite,
|
|
"repos:read" => Self::ReposRead,
|
|
"repos:write" => Self::ReposWrite,
|
|
other => Self::Unknown(other.to_string()),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Parse a slice of scope strings into `Vec<Scope>`.
|
|
pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
|
|
raw.iter()
|
|
.map(|s| {
|
|
s.as_ref()
|
|
.parse::<Scope>()
|
|
.expect("infallible: Scope::from_str cannot fail")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn round_trip() {
|
|
for scope in [Scope::MessagesRead, Scope::AdminChannels, Scope::FilesRead] {
|
|
let parsed: Scope = scope.as_str().parse().unwrap();
|
|
assert_eq!(parsed.as_str(), scope.as_str());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_scope_preserved() {
|
|
let scope: Scope = "future:capability".parse().unwrap();
|
|
assert_eq!(scope.as_str(), "future:capability");
|
|
assert!(matches!(scope, Scope::Unknown(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_scopes_slice() {
|
|
let scopes = parse_scopes(&["messages:read", "channels:write"]);
|
|
assert_eq!(scopes, vec![Scope::MessagesRead, Scope::ChannelsWrite]);
|
|
}
|
|
|
|
#[test]
|
|
fn all_non_admin_excludes_admin_scopes() {
|
|
let scopes = Scope::all_non_admin();
|
|
assert_eq!(scopes.len(), 14, "expected 14 non-admin scope variants");
|
|
// Verify no duplicates
|
|
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
|
|
assert_eq!(
|
|
unique.len(),
|
|
14,
|
|
"all_non_admin() must not contain duplicates"
|
|
);
|
|
// Verify no Unknown variants
|
|
for scope in &scopes {
|
|
assert!(
|
|
!matches!(scope, Scope::Unknown(_)),
|
|
"all_non_admin() must not contain Unknown variants"
|
|
);
|
|
}
|
|
// Verify admin scopes are excluded
|
|
assert!(
|
|
!scopes.contains(&Scope::AdminChannels),
|
|
"all_non_admin() must not contain AdminChannels"
|
|
);
|
|
assert!(
|
|
!scopes.contains(&Scope::AdminUsers),
|
|
"all_non_admin() must not contain AdminUsers"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn all_known_returns_all_known_variants() {
|
|
let all = Scope::all_known();
|
|
assert_eq!(all.len(), 16, "expected 16 known scope variants");
|
|
// Verify no duplicates
|
|
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
|
|
assert_eq!(unique.len(), 16, "all_known() must not contain duplicates");
|
|
// Verify no Unknown variants
|
|
for scope in &all {
|
|
assert!(
|
|
!matches!(scope, Scope::Unknown(_)),
|
|
"all_known() must not contain Unknown variants"
|
|
);
|
|
}
|
|
}
|
|
}
|