use super::error::ScopeError;
use super::parser::{
AccountAction, AccountAttr, BlobScope, IdentityAttr, IdentityScope, ParsedScope, RepoAction,
RepoScope, RpcScope, parse_scope_string,
};
use std::collections::HashSet;
use tranquil_types::Nsid;
#[derive(Debug, Clone)]
pub struct ScopePermissions {
scopes: HashSet<String>,
parsed: Vec<ParsedScope>,
has_transition_generic: bool,
has_transition_chat: bool,
has_transition_email: bool,
}
impl ScopePermissions {
pub fn from_scope_string(scope: Option<&str>) -> Self {
let scope_str = scope.unwrap_or("atproto");
let scopes: HashSet<String> = scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let parsed = parse_scope_string(scope_str);
let has_transition_generic = parsed
.iter()
.any(|p| matches!(p, ParsedScope::TransitionGeneric));
let has_transition_chat = parsed
.iter()
.any(|p| matches!(p, ParsedScope::TransitionChat));
let has_transition_email = parsed
.iter()
.any(|p| matches!(p, ParsedScope::TransitionEmail));
Self {
scopes,
parsed,
has_transition_generic,
has_transition_chat,
has_transition_email,
}
}
}
/// Whether holding `transition:generic` makes `scope` redundant.
pub fn superseded_by_transition_generic(scope: &ParsedScope) -> bool {
match scope {
ParsedScope::Repo(_) | ParsedScope::Blob(_) => true,
ParsedScope::Rpc(rpc) => !(rpc.lxm == "*" || rpc.lxm.starts_with("chat.bsky.")),
ParsedScope::Account(_)
| ParsedScope::Identity(_)
| ParsedScope::TransitionEmail
| ParsedScope::TransitionChat => false,
ParsedScope::Include(_) => false,
ParsedScope::TransitionGeneric | ParsedScope::Atproto | ParsedScope::Unknown(_) => false,
}
}
impl ScopePermissions {
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.contains(scope)
}
pub fn scopes(&self) -> &HashSet<String> {
&self.scopes
}
pub fn has_full_access(&self) -> bool {
self.has_transition_generic
}
fn find_repo_scopes(&self) -> impl Iterator<Item = &RepoScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Repo(r) = p {
Some(r)
} else {
None
}
})
}
fn find_blob_scopes(&self) -> impl Iterator<Item = &BlobScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Blob(b) = p {
Some(b)
} else {
None
}
})
}
fn find_rpc_scopes(&self) -> impl Iterator<Item = &RpcScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Rpc(r) = p {
Some(r)
} else {
None
}
})
}
fn find_account_scopes(&self) -> impl Iterator<Item = &super::parser::AccountScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Account(a) = p {
Some(a)
} else {
None
}
})
}
fn find_identity_scopes(&self) -> impl Iterator<Item = &IdentityScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Identity(i) = p {
Some(i)
} else {
None
}
})
}
pub fn assert_repo(&self, action: RepoAction, collection: &Nsid) -> Result<(), ScopeError> {
if self.has_transition_generic {
return Ok(());
}
let has_repo_permission = self.find_repo_scopes().any(|repo_scope| {
repo_scope.actions.contains(&action)
&& match &repo_scope.collection {
None => true,
Some(coll) if coll == collection => true,
Some(coll) if coll.ends_with(".*") => {
let prefix = coll.strip_suffix(".*").unwrap();
collection.starts_with(prefix)
&& collection.chars().nth(prefix.len()) == Some('.')
}
_ => false,
}
});
if has_repo_permission {
Ok(())
} else {
Err(ScopeError::InsufficientScope {
required: format!("repo:{}?action={}", collection, action_str(action)),
message: format!(
"Insufficient scope to {} records in {}",
action_str(action),
collection
),
})
}
}
pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> {
if self.has_transition_generic {
return Ok(());
}
if self
.find_blob_scopes()
.any(|blob_scope| blob_scope.matches_mime(mime))
{
Ok(())
} else {
Err(ScopeError::InsufficientScope {
required: format!("blob:{}", mime),
message: format!("Insufficient scope to upload blob with mime type {}", mime),
})
}
}
pub fn assert_rpc(&self, aud: &str, lxm: &Nsid) -> Result<(), ScopeError> {
let is_chat = lxm.starts_with("chat.bsky.");
if is_chat && self.has_transition_chat {
return Ok(());
}
// `transition:generic` covers every lexicon except chat. Note it does not *block* chat:
// holding it must never remove access a granular `rpc:chat.bsky.*` scope would grant on
// its own, so chat requests fall through to the granular check below rather than
// failing here.
if self.has_transition_generic && !is_chat {
return Ok(());
}
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
let lxm_matches = match &rpc_scope.lxm {
scope_lxm if scope_lxm == "*" => true,
scope_lxm if scope_lxm == lxm => true,
scope_lxm if scope_lxm.ends_with(".*") => {
let prefix = scope_lxm.strip_suffix(".*").unwrap();
lxm.starts_with(prefix) && lxm.chars().nth(prefix.len()) == Some('.')
}
_ => false,
};
let aud_matches = match &rpc_scope.aud {
None => false,
Some(scope_aud) if scope_aud == "*" => true,
Some(scope_aud) => scope_aud == aud,
};
lxm_matches && aud_matches
});
if has_permission {
return Ok(());
}
// Point a caller holding only `transition:generic` at the scope it actually needs,
// rather than at a granular rpc scope it probably did not mean to request.
Err(match is_chat && self.has_transition_generic {
true => ScopeError::InsufficientScope {
required: "transition:chat.bsky".to_string(),
message: format!(
"Chat access requires transition:chat.bsky scope to call {}",
lxm
),
},
false => ScopeError::InsufficientScope {
required: format!("rpc:{}?aud={}", lxm, aud),
message: format!("Insufficient scope to call {} on {}", lxm, aud),
},
})
}
pub fn assert_account(
&self,
attr: AccountAttr,
action: AccountAction,
) -> Result<(), ScopeError> {
if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email
{
return Ok(());
}
let has_permission = self.find_account_scopes().any(|account_scope| {
(account_scope.attr == attr || account_scope.attr == AccountAttr::Wildcard)
&& (account_scope.action == action || account_scope.action == AccountAction::Manage)
});
if has_permission {
Ok(())
} else {
Err(ScopeError::InsufficientScope {
required: format!(
"account:{}?action={}",
attr_str(attr),
action_str_account(action)
),
message: format!(
"Insufficient scope to {} account {}",
action_str_account(action),
attr_str(attr)
),
})
}
}
pub fn allows_email_read(&self) -> bool {
self.has_transition_email
|| self
.find_account_scopes()
.any(|a| a.attr == AccountAttr::Email || a.attr == AccountAttr::Wildcard)
}
pub fn allows_repo(&self, action: RepoAction, collection: &Nsid) -> bool {
self.assert_repo(action, collection).is_ok()
}
pub fn allows_blob(&self, mime: &str) -> bool {
self.assert_blob(mime).is_ok()
}
pub fn allows_rpc(&self, aud: &str, lxm: &Nsid) -> bool {
self.assert_rpc(aud, lxm).is_ok()
}
pub fn allows_account(&self, attr: AccountAttr, action: AccountAction) -> bool {
self.assert_account(attr, action).is_ok()
}
pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> {
let has_permission = self.find_identity_scopes().any(|identity_scope| {
identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr
});
if has_permission {
Ok(())
} else {
Err(ScopeError::InsufficientScope {
required: format!("identity:{}", identity_attr_str(attr)),
message: format!(
"Insufficient scope to modify identity {}",
identity_attr_str(attr)
),
})
}
}
pub fn allows_identity(&self, attr: IdentityAttr) -> bool {
self.assert_identity(attr).is_ok()
}
}
fn action_str(action: RepoAction) -> &'static str {
match action {
RepoAction::Create => "create",
RepoAction::Update => "update",
RepoAction::Delete => "delete",
}
}
fn attr_str(attr: AccountAttr) -> &'static str {
match attr {
AccountAttr::Email => "email",
AccountAttr::Handle => "handle",
AccountAttr::Repo => "repo",
AccountAttr::Status => "status",
AccountAttr::Wildcard => "*",
}
}
fn identity_attr_str(attr: IdentityAttr) -> &'static str {
match attr {
IdentityAttr::Handle => "handle",
IdentityAttr::Wildcard => "*",
}
}
fn action_str_account(action: AccountAction) -> &'static str {
match action {
AccountAction::Read => "read",
AccountAction::Manage => "manage",
}
}
impl Default for ScopePermissions {
fn default() -> Self {
Self::from_scope_string(Some("atproto"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_scope;
fn c(s: &str) -> Nsid {
s.parse().unwrap()
}
#[test]
fn test_atproto_scope_is_identity_only() {
let perms = ScopePermissions::from_scope_string(Some("atproto"));
assert!(!perms.has_full_access());
assert!(!perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(!perms.allows_blob("image/png"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
}
#[test]
fn test_transition_generic_allows_everything() {
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
assert!(perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(perms.allows_blob("image/png"));
}
#[test]
fn test_transition_generic_without_chat_blocks_chat() {
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("chat.bsky.convo.listConvos")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("chat.bsky.convo.getMessages")));
}
#[test]
fn test_transition_generic_with_chat_allows_chat() {
let perms =
ScopePermissions::from_scope_string(Some("transition:generic transition:chat.bsky"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("chat.bsky.convo.listConvos")));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("chat.bsky.convo.getMessages")));
}
#[test]
fn test_transition_chat_only_allows_chat() {
let perms = ScopePermissions::from_scope_string(Some("transition:chat.bsky"));
assert!(!perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("chat.bsky.convo.getMessages")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_empty_scope_defaults_to_atproto() {
let perms = ScopePermissions::from_scope_string(None);
assert!(perms.has_scope("atproto"));
assert!(!perms.has_full_access());
assert!(!perms.allows_repo(RepoAction::Create, &c("cafe.oyster.record")));
}
#[test]
fn test_multiple_scopes() {
let perms = ScopePermissions::from_scope_string(Some("atproto transition:chat.bsky"));
assert!(perms.has_scope("atproto"));
assert!(perms.has_scope("transition:chat.bsky"));
assert!(!perms.has_scope("transition:generic"));
}
#[test]
fn test_transition_email_allows_email_read() {
let perms = ScopePermissions::from_scope_string(Some("transition:email"));
assert!(perms.allows_email_read());
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(!perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
}
#[test]
fn test_granular_repo_wildcard() {
let perms =
ScopePermissions::from_scope_string(Some("atproto repo:*?action=create blob:*/*"));
assert!(perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(perms.allows_repo(RepoAction::Create, &c("cafe.oyster.record")));
assert!(perms.allows_blob("image/png"));
}
#[test]
fn test_granular_repo_collection_specific() {
let perms = ScopePermissions::from_scope_string(Some(
"repo:app.bsky.feed.post?action=create&action=delete",
));
assert!(perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(perms.allows_repo(RepoAction::Delete, &c("app.bsky.feed.post")));
assert!(!perms.allows_repo(RepoAction::Update, &c("app.bsky.feed.post")));
assert!(!perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.like")));
}
#[test]
fn test_granular_blob_specific_mime() {
let perms = ScopePermissions::from_scope_string(Some("blob?accept=image/*&accept=video/*"));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_blob("image/jpeg"));
assert!(perms.allows_blob("video/mp4"));
assert!(!perms.allows_blob("text/plain"));
assert!(!perms.allows_blob("application/json"));
}
#[test]
fn test_granular_rpc() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(!perms.allows_rpc("did:web:other.service", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_granular_rpc_wildcard_aud() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getTimeline?aud=*"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
assert!(perms.allows_rpc("did:web:any.service", &c("app.bsky.feed.getTimeline")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
}
#[test]
fn test_rpc_wildcard_lxm() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:*?aud=did:web:api.bsky.app#bsky_appview",
));
let aud = "did:web:api.bsky.app#bsky_appview";
let other = "did:web:other.app#bsky_appview";
assert!(perms.allows_rpc(aud, &c("app.bsky.feed.getTimeline")));
assert!(!perms.allows_rpc(other, &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_rpc_wildcard_lxm_without_aud() {
let perms = ScopePermissions::from_scope_string(Some("rpc:*"));
let aud = "did:web:api.bsky.app#bsky_appview";
assert!(!perms.allows_rpc(aud, &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_granular_account() {
let perms = ScopePermissions::from_scope_string(Some("account:email?action=read"));
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(!perms.allows_account(AccountAttr::Handle, AccountAction::Read));
let perms2 = ScopePermissions::from_scope_string(Some("account:repo?action=manage"));
assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Manage));
assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Read));
}
#[test]
fn test_granular_scopes_without_atproto() {
let perms = ScopePermissions::from_scope_string(Some("repo:*?action=create"));
assert!(!perms.has_full_access());
assert!(perms.allows_repo(RepoAction::Create, &c("cafe.oyster.record")));
assert!(!perms.allows_repo(RepoAction::Update, &c("cafe.oyster.record")));
assert!(!perms.allows_repo(RepoAction::Delete, &c("cafe.oyster.record")));
}
#[test]
fn test_pdsls_style_scopes() {
let perms = ScopePermissions::from_scope_string(Some(
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
));
assert!(perms.allows_repo(RepoAction::Create, &c("cafe.oyster.record")));
assert!(perms.allows_repo(RepoAction::Update, &c("cafe.oyster.record")));
assert!(perms.allows_repo(RepoAction::Delete, &c("cafe.oyster.record")));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_blob("video/mp4"));
}
#[test]
fn test_identity_scope_handle() {
let perms = ScopePermissions::from_scope_string(Some("identity:handle"));
assert!(perms.allows_identity(IdentityAttr::Handle));
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_identity_scope_wildcard() {
let perms = ScopePermissions::from_scope_string(Some("identity:*"));
assert!(perms.allows_identity(IdentityAttr::Handle));
assert!(perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_identity_scope_with_atproto_alone() {
let perms = ScopePermissions::from_scope_string(Some("atproto"));
assert!(!perms.allows_identity(IdentityAttr::Handle));
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_transition_generic_does_not_grant_identity() {
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
assert!(!perms.allows_identity(IdentityAttr::Handle));
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_account_status_scope() {
let perms = ScopePermissions::from_scope_string(Some("account:status?action=read"));
assert!(perms.allows_account(AccountAttr::Status, AccountAction::Read));
assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage));
}
#[test]
fn test_atproto_with_granular_scopes_uses_granular() {
let perms =
ScopePermissions::from_scope_string(Some("atproto repo:*?action=create blob:*/*"));
assert!(!perms.has_full_access());
assert!(perms.allows_repo(RepoAction::Create, &c("cafe.oyster.record")));
assert!(!perms.allows_repo(RepoAction::Delete, &c("cafe.oyster.record")));
assert!(!perms.allows_repo(RepoAction::Update, &c("cafe.oyster.record")));
assert!(perms.allows_blob("image/png"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_atproto_alone_grants_nothing() {
let perms = ScopePermissions::from_scope_string(Some("atproto"));
assert!(!perms.has_full_access());
assert!(!perms.allows_repo(RepoAction::Create, &c("cafe.oyster.record")));
assert!(!perms.allows_repo(RepoAction::Delete, &c("cafe.oyster.record")));
assert!(!perms.allows_repo(RepoAction::Update, &c("cafe.oyster.record")));
assert!(!perms.allows_blob("image/png"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_rpc_scope_with_did_fragment() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview",
));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(
!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")),
"a scope naming one service must not cover the whole DID"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
),
"a scope naming one service must not cover a sibling service on the same DID"
);
assert!(!perms.allows_rpc("did:web:other.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_rpc_scope_without_fragment_does_not_cover_service_ids() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
),
"the audience is compared verbatim, so a bare DID grants nothing to its services"
);
}
#[test]
fn test_rpc_scope_aud_wildcard_covers_service_ids() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getAuthorFeed?aud=*"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
));
}
#[test]
fn transition_generic_supersedes_granular_scopes() {
for scope in [
"repo:app.bsky.feed.post?action=create",
"blob:image/png",
"rpc:app.bsky.actor.getProfile?aud=*",
] {
assert!(
superseded_by_transition_generic(&parse_scope(scope)),
"{scope} should be superseded by transition:generic"
);
}
}
#[test]
fn transition_generic_does_not_supersede_chat() {
// assert_rpc rejects chat.bsky.* when transition:generic is held without
// transition:chat.bsky, so neither the transition scope nor an rpc scope that
// could reach a chat lexicon is covered by it.
for scope in [
"transition:chat.bsky",
"rpc:chat.bsky.convo.sendMessage?aud=*",
"rpc:*?aud=did:web:api.bsky.app",
"account:email?action=manage",
"account:email?action=read",
"account:status?action=read",
"identity:handle",
"identity:*",
"transition:email",
] {
assert!(
!superseded_by_transition_generic(&parse_scope(scope)),
"{scope} must not be treated as superseded"
);
}
}
#[test]
fn transition_generic_does_not_supersede_itself_or_baseline() {
assert!(!superseded_by_transition_generic(&parse_scope(
"transition:generic"
)));
assert!(!superseded_by_transition_generic(&parse_scope("atproto")));
}
#[test]
fn superseded_matches_enforcement_for_chat_and_feed() {
// Cross-check against ScopePermissions so the two cannot drift apart.
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
let feed = Nsid::new("app.bsky.feed.getTimeline").unwrap();
let chat = Nsid::new("chat.bsky.convo.sendMessage").unwrap();
assert!(perms.allows_rpc("did:web:api.bsky.app", &feed));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &chat));
}
#[test]
fn granular_chat_rpc_works_without_transition_generic() {
// Baseline for the test below: on its own, a granular chat rpc scope grants chat.
let perms = ScopePermissions::from_scope_string(Some(
"atproto rpc:chat.bsky.convo.sendMessage?aud=*",
));
assert!(perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.sendMessage")));
}
#[test]
fn transition_generic_does_not_revoke_granular_chat_rpc() {
// Adding a broader scope must never remove access. transition:generic does not cover
// chat lexicons, but it must not stop a granular chat rpc scope from doing so either.
let perms = ScopePermissions::from_scope_string(Some(
"atproto transition:generic rpc:chat.bsky.convo.sendMessage?aud=*",
));
assert!(perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.sendMessage")));
// ...and still grants everything else it covers.
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn transition_generic_does_not_widen_granular_chat_rpc() {
// The granular scope grants exactly one chat lexicon; transition:generic must not be
// read as covering the rest of chat.
let perms = ScopePermissions::from_scope_string(Some(
"atproto transition:generic rpc:chat.bsky.convo.sendMessage?aud=*",
));
assert!(!perms.allows_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.deleteMessage")));
}
#[test]
fn chat_denial_still_names_the_scope_the_caller_needs() {
// transition:generic alone: the useful advice is "ask for transition:chat.bsky",
// not "ask for rpc:chat.bsky.convo.listConvos".
let generic = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
let err = generic
.assert_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.listConvos"))
.expect_err("chat must be denied without transition:chat.bsky");
match err {
ScopeError::InsufficientScope { required, .. } => {
assert_eq!(required, "transition:chat.bsky");
}
other => panic!("unexpected error: {other:?}"),
}
// Without transition:generic the granular scope is the right thing to name.
let bare = ScopePermissions::from_scope_string(Some("atproto"));
let err = bare
.assert_rpc("did:web:api.bsky.chat", &c("chat.bsky.convo.listConvos"))
.expect_err("chat must be denied with no rpc scope at all");
match err {
ScopeError::InsufficientScope { required, .. } => {
assert!(required.starts_with("rpc:chat.bsky.convo.listConvos"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn transition_generic_does_not_grant_account_management() {
// "no account management actions: change handle, change email, delete or deactivate
// account, migrate account" -- atproto OAuth spec.
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(!perms.allows_account(AccountAttr::Repo, AccountAction::Manage));
assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage));
}
#[test]
fn transition_generic_does_not_grant_email_read() {
// Reading the account email is what transition:email is for.
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
assert!(!perms.allows_email_read());
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Read));
}
#[test]
fn granular_scopes_still_grant_alongside_transition_generic() {
// Removing the short-circuit must not stop an explicitly granted scope from working.
let perms = ScopePermissions::from_scope_string(Some(
"atproto transition:generic account:email?action=manage identity:handle",
));
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(perms.allows_identity(IdentityAttr::Handle));
let with_email = ScopePermissions::from_scope_string(Some(
"atproto transition:generic transition:email",
));
assert!(with_email.allows_email_read());
}
#[test]
fn transition_generic_still_grants_what_the_spec_says_it_does() {
let perms = ScopePermissions::from_scope_string(Some("atproto transition:generic"));
assert!(perms.allows_repo(RepoAction::Create, &c("app.bsky.feed.post")));
assert!(perms.allows_repo(RepoAction::Delete, &c("app.bsky.feed.post")));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
}