this repo has no description
1use super::error::ScopeError; 2use super::parser::{ 3 AccountAction, AccountAttr, BlobScope, IdentityAttr, IdentityScope, ParsedScope, RepoAction, 4 RepoScope, RpcScope, parse_scope_string, 5}; 6use std::collections::HashSet; 7 8#[derive(Debug, Clone)] 9pub struct ScopePermissions { 10 scopes: HashSet<String>, 11 parsed: Vec<ParsedScope>, 12 has_atproto: bool, 13 has_transition_generic: bool, 14 has_transition_chat: bool, 15 has_transition_email: bool, 16} 17 18impl ScopePermissions { 19 pub fn from_scope_string(scope: Option<&str>) -> Self { 20 let scope_str = scope.unwrap_or("atproto"); 21 let scopes: HashSet<String> = scope_str 22 .split_whitespace() 23 .map(|s| s.to_string()) 24 .collect(); 25 26 let parsed = parse_scope_string(scope_str); 27 28 let has_atproto = parsed.iter().any(|p| matches!(p, ParsedScope::Atproto)); 29 let has_transition_generic = parsed 30 .iter() 31 .any(|p| matches!(p, ParsedScope::TransitionGeneric)); 32 let has_transition_chat = parsed 33 .iter() 34 .any(|p| matches!(p, ParsedScope::TransitionChat)); 35 let has_transition_email = parsed 36 .iter() 37 .any(|p| matches!(p, ParsedScope::TransitionEmail)); 38 39 Self { 40 scopes, 41 parsed, 42 has_atproto, 43 has_transition_generic, 44 has_transition_chat, 45 has_transition_email, 46 } 47 } 48 49 pub fn has_scope(&self, scope: &str) -> bool { 50 self.scopes.contains(scope) 51 } 52 53 pub fn scopes(&self) -> &HashSet<String> { 54 &self.scopes 55 } 56 57 pub fn has_full_access(&self) -> bool { 58 self.has_atproto 59 } 60 61 fn find_repo_scopes(&self) -> impl Iterator<Item = &RepoScope> { 62 self.parsed.iter().filter_map(|p| { 63 if let ParsedScope::Repo(r) = p { 64 Some(r) 65 } else { 66 None 67 } 68 }) 69 } 70 71 fn find_blob_scopes(&self) -> impl Iterator<Item = &BlobScope> { 72 self.parsed.iter().filter_map(|p| { 73 if let ParsedScope::Blob(b) = p { 74 Some(b) 75 } else { 76 None 77 } 78 }) 79 } 80 81 fn find_rpc_scopes(&self) -> impl Iterator<Item = &RpcScope> { 82 self.parsed.iter().filter_map(|p| { 83 if let ParsedScope::Rpc(r) = p { 84 Some(r) 85 } else { 86 None 87 } 88 }) 89 } 90 91 fn find_account_scopes(&self) -> impl Iterator<Item = &super::parser::AccountScope> { 92 self.parsed.iter().filter_map(|p| { 93 if let ParsedScope::Account(a) = p { 94 Some(a) 95 } else { 96 None 97 } 98 }) 99 } 100 101 fn find_identity_scopes(&self) -> impl Iterator<Item = &IdentityScope> { 102 self.parsed.iter().filter_map(|p| { 103 if let ParsedScope::Identity(i) = p { 104 Some(i) 105 } else { 106 None 107 } 108 }) 109 } 110 111 pub fn assert_repo(&self, action: RepoAction, collection: &str) -> Result<(), ScopeError> { 112 if self.has_atproto || self.has_transition_generic { 113 return Ok(()); 114 } 115 116 for repo_scope in self.find_repo_scopes() { 117 if !repo_scope.actions.contains(&action) { 118 continue; 119 } 120 121 match &repo_scope.collection { 122 None => return Ok(()), 123 Some(coll) if coll == collection => return Ok(()), 124 Some(coll) if coll.ends_with(".*") => { 125 let prefix = coll.strip_suffix(".*").unwrap(); 126 if collection.starts_with(prefix) 127 && collection.chars().nth(prefix.len()) == Some('.') 128 { 129 return Ok(()); 130 } 131 } 132 _ => {} 133 } 134 } 135 136 Err(ScopeError::InsufficientScope { 137 required: format!("repo:{}?action={}", collection, action_str(action)), 138 message: format!( 139 "Insufficient scope to {} records in {}", 140 action_str(action), 141 collection 142 ), 143 }) 144 } 145 146 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { 147 if self.has_atproto || self.has_transition_generic { 148 return Ok(()); 149 } 150 151 for blob_scope in self.find_blob_scopes() { 152 if blob_scope.matches_mime(mime) { 153 return Ok(()); 154 } 155 } 156 157 Err(ScopeError::InsufficientScope { 158 required: format!("blob:{}", mime), 159 message: format!("Insufficient scope to upload blob with mime type {}", mime), 160 }) 161 } 162 163 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { 164 if self.has_atproto || self.has_transition_generic { 165 return Ok(()); 166 } 167 168 if lxm.starts_with("chat.bsky.") && self.has_transition_chat { 169 return Ok(()); 170 } 171 172 for rpc_scope in self.find_rpc_scopes() { 173 let lxm_matches = match &rpc_scope.lxm { 174 None => true, 175 Some(scope_lxm) if scope_lxm == lxm => true, 176 Some(scope_lxm) if scope_lxm.ends_with(".*") => { 177 let prefix = scope_lxm.strip_suffix(".*").unwrap(); 178 lxm.starts_with(prefix) && lxm.chars().nth(prefix.len()) == Some('.') 179 } 180 _ => false, 181 }; 182 183 let aud_matches = match &rpc_scope.aud { 184 None => true, 185 Some(scope_aud) if scope_aud == "*" => true, 186 Some(scope_aud) => scope_aud == aud, 187 }; 188 189 if lxm_matches && aud_matches { 190 return Ok(()); 191 } 192 } 193 194 Err(ScopeError::InsufficientScope { 195 required: format!("rpc:{}?aud={}", lxm, aud), 196 message: format!("Insufficient scope to call {} on {}", lxm, aud), 197 }) 198 } 199 200 pub fn assert_account( 201 &self, 202 attr: AccountAttr, 203 action: AccountAction, 204 ) -> Result<(), ScopeError> { 205 if self.has_atproto || self.has_transition_generic { 206 return Ok(()); 207 } 208 209 if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email 210 { 211 return Ok(()); 212 } 213 214 for account_scope in self.find_account_scopes() { 215 if account_scope.attr == attr && account_scope.action == action { 216 return Ok(()); 217 } 218 if account_scope.attr == attr && account_scope.action == AccountAction::Manage { 219 return Ok(()); 220 } 221 } 222 223 Err(ScopeError::InsufficientScope { 224 required: format!( 225 "account:{}?action={}", 226 attr_str(attr), 227 action_str_account(action) 228 ), 229 message: format!( 230 "Insufficient scope to {} account {}", 231 action_str_account(action), 232 attr_str(attr) 233 ), 234 }) 235 } 236 237 pub fn allows_email_read(&self) -> bool { 238 self.has_atproto 239 || self.has_transition_generic 240 || self.has_transition_email 241 || self 242 .find_account_scopes() 243 .any(|a| a.attr == AccountAttr::Email) 244 } 245 246 pub fn allows_repo(&self, action: RepoAction, collection: &str) -> bool { 247 self.assert_repo(action, collection).is_ok() 248 } 249 250 pub fn allows_blob(&self, mime: &str) -> bool { 251 self.assert_blob(mime).is_ok() 252 } 253 254 pub fn allows_rpc(&self, aud: &str, lxm: &str) -> bool { 255 self.assert_rpc(aud, lxm).is_ok() 256 } 257 258 pub fn allows_account(&self, attr: AccountAttr, action: AccountAction) -> bool { 259 self.assert_account(attr, action).is_ok() 260 } 261 262 pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> { 263 if self.has_atproto || self.has_transition_generic { 264 return Ok(()); 265 } 266 267 for identity_scope in self.find_identity_scopes() { 268 if identity_scope.attr == IdentityAttr::Wildcard { 269 return Ok(()); 270 } 271 if identity_scope.attr == attr { 272 return Ok(()); 273 } 274 } 275 276 Err(ScopeError::InsufficientScope { 277 required: format!("identity:{}", identity_attr_str(attr)), 278 message: format!( 279 "Insufficient scope to modify identity {}", 280 identity_attr_str(attr) 281 ), 282 }) 283 } 284 285 pub fn allows_identity(&self, attr: IdentityAttr) -> bool { 286 self.assert_identity(attr).is_ok() 287 } 288} 289 290fn action_str(action: RepoAction) -> &'static str { 291 match action { 292 RepoAction::Create => "create", 293 RepoAction::Update => "update", 294 RepoAction::Delete => "delete", 295 } 296} 297 298fn attr_str(attr: AccountAttr) -> &'static str { 299 match attr { 300 AccountAttr::Email => "email", 301 AccountAttr::Handle => "handle", 302 AccountAttr::Repo => "repo", 303 AccountAttr::Status => "status", 304 } 305} 306 307fn identity_attr_str(attr: IdentityAttr) -> &'static str { 308 match attr { 309 IdentityAttr::Handle => "handle", 310 IdentityAttr::Wildcard => "*", 311 } 312} 313 314fn action_str_account(action: AccountAction) -> &'static str { 315 match action { 316 AccountAction::Read => "read", 317 AccountAction::Manage => "manage", 318 } 319} 320 321impl Default for ScopePermissions { 322 fn default() -> Self { 323 Self::from_scope_string(Some("atproto")) 324 } 325} 326 327#[cfg(test)] 328mod tests { 329 use super::*; 330 331 #[test] 332 fn test_atproto_scope_allows_everything() { 333 let perms = ScopePermissions::from_scope_string(Some("atproto")); 334 assert!(perms.has_full_access()); 335 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 336 assert!(perms.allows_blob("image/png")); 337 assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 338 assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage)); 339 } 340 341 #[test] 342 fn test_transition_generic_allows_everything() { 343 let perms = ScopePermissions::from_scope_string(Some("transition:generic")); 344 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 345 assert!(perms.allows_blob("image/png")); 346 } 347 348 #[test] 349 fn test_transition_chat_only_allows_chat() { 350 let perms = ScopePermissions::from_scope_string(Some("transition:chat.bsky")); 351 assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 352 assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages")); 353 assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 354 } 355 356 #[test] 357 fn test_empty_scope_defaults_to_atproto() { 358 let perms = ScopePermissions::from_scope_string(None); 359 assert!(perms.has_full_access()); 360 } 361 362 #[test] 363 fn test_multiple_scopes() { 364 let perms = ScopePermissions::from_scope_string(Some("atproto transition:chat.bsky")); 365 assert!(perms.has_scope("atproto")); 366 assert!(perms.has_scope("transition:chat.bsky")); 367 assert!(!perms.has_scope("transition:generic")); 368 } 369 370 #[test] 371 fn test_transition_email_allows_email_read() { 372 let perms = ScopePermissions::from_scope_string(Some("transition:email")); 373 assert!(perms.allows_email_read()); 374 assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read)); 375 assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage)); 376 assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 377 } 378 379 #[test] 380 fn test_granular_repo_wildcard() { 381 let perms = 382 ScopePermissions::from_scope_string(Some("atproto repo:*?action=create blob:*/*")); 383 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 384 assert!(perms.allows_repo(RepoAction::Create, "any.collection")); 385 assert!(perms.allows_blob("image/png")); 386 } 387 388 #[test] 389 fn test_granular_repo_collection_specific() { 390 let perms = ScopePermissions::from_scope_string(Some( 391 "repo:app.bsky.feed.post?action=create&action=delete", 392 )); 393 assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post")); 394 assert!(perms.allows_repo(RepoAction::Delete, "app.bsky.feed.post")); 395 assert!(!perms.allows_repo(RepoAction::Update, "app.bsky.feed.post")); 396 assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.like")); 397 } 398 399 #[test] 400 fn test_granular_blob_specific_mime() { 401 let perms = ScopePermissions::from_scope_string(Some("blob?accept=image/*&accept=video/*")); 402 assert!(perms.allows_blob("image/png")); 403 assert!(perms.allows_blob("image/jpeg")); 404 assert!(perms.allows_blob("video/mp4")); 405 assert!(!perms.allows_blob("text/plain")); 406 assert!(!perms.allows_blob("application/json")); 407 } 408 409 #[test] 410 fn test_granular_rpc() { 411 let perms = ScopePermissions::from_scope_string(Some( 412 "rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app", 413 )); 414 assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 415 assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); 416 assert!(!perms.allows_rpc("did:web:other.service", "app.bsky.feed.getTimeline")); 417 } 418 419 #[test] 420 fn test_granular_rpc_wildcard_aud() { 421 let perms = 422 ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getTimeline?aud=*")); 423 assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 424 assert!(perms.allows_rpc("did:web:any.service", "app.bsky.feed.getTimeline")); 425 assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); 426 } 427 428 #[test] 429 fn test_granular_account() { 430 let perms = ScopePermissions::from_scope_string(Some("account:email?action=read")); 431 assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read)); 432 assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage)); 433 assert!(!perms.allows_account(AccountAttr::Handle, AccountAction::Read)); 434 435 let perms2 = ScopePermissions::from_scope_string(Some("account:repo?action=manage")); 436 assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Manage)); 437 assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Read)); 438 } 439 440 #[test] 441 fn test_granular_scopes_without_atproto() { 442 let perms = ScopePermissions::from_scope_string(Some("repo:*?action=create")); 443 assert!(!perms.has_full_access()); 444 assert!(perms.allows_repo(RepoAction::Create, "any.collection")); 445 assert!(!perms.allows_repo(RepoAction::Update, "any.collection")); 446 assert!(!perms.allows_repo(RepoAction::Delete, "any.collection")); 447 } 448 449 #[test] 450 fn test_pdsls_style_scopes() { 451 let perms = ScopePermissions::from_scope_string(Some( 452 "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 453 )); 454 assert!(perms.allows_repo(RepoAction::Create, "any.collection")); 455 assert!(perms.allows_repo(RepoAction::Update, "any.collection")); 456 assert!(perms.allows_repo(RepoAction::Delete, "any.collection")); 457 assert!(perms.allows_blob("image/png")); 458 assert!(perms.allows_blob("video/mp4")); 459 } 460 461 #[test] 462 fn test_identity_scope_handle() { 463 let perms = ScopePermissions::from_scope_string(Some("identity:handle")); 464 assert!(perms.allows_identity(IdentityAttr::Handle)); 465 assert!(!perms.allows_identity(IdentityAttr::Wildcard)); 466 } 467 468 #[test] 469 fn test_identity_scope_wildcard() { 470 let perms = ScopePermissions::from_scope_string(Some("identity:*")); 471 assert!(perms.allows_identity(IdentityAttr::Handle)); 472 assert!(perms.allows_identity(IdentityAttr::Wildcard)); 473 } 474 475 #[test] 476 fn test_identity_scope_with_atproto() { 477 let perms = ScopePermissions::from_scope_string(Some("atproto")); 478 assert!(perms.allows_identity(IdentityAttr::Handle)); 479 assert!(perms.allows_identity(IdentityAttr::Wildcard)); 480 } 481 482 #[test] 483 fn test_account_status_scope() { 484 let perms = ScopePermissions::from_scope_string(Some("account:status?action=read")); 485 assert!(perms.allows_account(AccountAttr::Status, AccountAction::Read)); 486 assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage)); 487 } 488}