this repo has no description
at main 17 kB view raw
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 let has_permission = self.find_repo_scopes().any(|repo_scope| { 117 repo_scope.actions.contains(&action) 118 && match &repo_scope.collection { 119 None => true, 120 Some(coll) if coll == collection => true, 121 Some(coll) if coll.ends_with(".*") => { 122 let prefix = coll.strip_suffix(".*").unwrap(); 123 collection.starts_with(prefix) 124 && collection.chars().nth(prefix.len()) == Some('.') 125 } 126 _ => false, 127 } 128 }); 129 130 if has_permission { 131 Ok(()) 132 } else { 133 Err(ScopeError::InsufficientScope { 134 required: format!("repo:{}?action={}", collection, action_str(action)), 135 message: format!( 136 "Insufficient scope to {} records in {}", 137 action_str(action), 138 collection 139 ), 140 }) 141 } 142 } 143 144 pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> { 145 if self.has_atproto || self.has_transition_generic { 146 return Ok(()); 147 } 148 149 if self.find_blob_scopes().any(|blob_scope| blob_scope.matches_mime(mime)) { 150 Ok(()) 151 } else { 152 Err(ScopeError::InsufficientScope { 153 required: format!("blob:{}", mime), 154 message: format!("Insufficient scope to upload blob with mime type {}", mime), 155 }) 156 } 157 } 158 159 pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> { 160 if self.has_atproto || self.has_transition_generic { 161 return Ok(()); 162 } 163 164 if lxm.starts_with("chat.bsky.") && self.has_transition_chat { 165 return Ok(()); 166 } 167 168 let has_permission = self.find_rpc_scopes().any(|rpc_scope| { 169 let lxm_matches = match &rpc_scope.lxm { 170 None => true, 171 Some(scope_lxm) if scope_lxm == lxm => true, 172 Some(scope_lxm) if scope_lxm.ends_with(".*") => { 173 let prefix = scope_lxm.strip_suffix(".*").unwrap(); 174 lxm.starts_with(prefix) && lxm.chars().nth(prefix.len()) == Some('.') 175 } 176 _ => false, 177 }; 178 179 let aud_matches = match &rpc_scope.aud { 180 None => true, 181 Some(scope_aud) if scope_aud == "*" => true, 182 Some(scope_aud) => scope_aud == aud, 183 }; 184 185 lxm_matches && aud_matches 186 }); 187 188 if has_permission { 189 Ok(()) 190 } else { 191 Err(ScopeError::InsufficientScope { 192 required: format!("rpc:{}?aud={}", lxm, aud), 193 message: format!("Insufficient scope to call {} on {}", lxm, aud), 194 }) 195 } 196 } 197 198 pub fn assert_account( 199 &self, 200 attr: AccountAttr, 201 action: AccountAction, 202 ) -> Result<(), ScopeError> { 203 if self.has_atproto || self.has_transition_generic { 204 return Ok(()); 205 } 206 207 if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email 208 { 209 return Ok(()); 210 } 211 212 let has_permission = self.find_account_scopes().any(|account_scope| { 213 account_scope.attr == attr 214 && (account_scope.action == action 215 || account_scope.action == AccountAction::Manage) 216 }); 217 218 if has_permission { 219 Ok(()) 220 } else { 221 Err(ScopeError::InsufficientScope { 222 required: format!( 223 "account:{}?action={}", 224 attr_str(attr), 225 action_str_account(action) 226 ), 227 message: format!( 228 "Insufficient scope to {} account {}", 229 action_str_account(action), 230 attr_str(attr) 231 ), 232 }) 233 } 234 } 235 236 pub fn allows_email_read(&self) -> bool { 237 self.has_atproto 238 || self.has_transition_generic 239 || self.has_transition_email 240 || self 241 .find_account_scopes() 242 .any(|a| a.attr == AccountAttr::Email) 243 } 244 245 pub fn allows_repo(&self, action: RepoAction, collection: &str) -> bool { 246 self.assert_repo(action, collection).is_ok() 247 } 248 249 pub fn allows_blob(&self, mime: &str) -> bool { 250 self.assert_blob(mime).is_ok() 251 } 252 253 pub fn allows_rpc(&self, aud: &str, lxm: &str) -> bool { 254 self.assert_rpc(aud, lxm).is_ok() 255 } 256 257 pub fn allows_account(&self, attr: AccountAttr, action: AccountAction) -> bool { 258 self.assert_account(attr, action).is_ok() 259 } 260 261 pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> { 262 if self.has_atproto || self.has_transition_generic { 263 return Ok(()); 264 } 265 266 let has_permission = self 267 .find_identity_scopes() 268 .any(|identity_scope| { 269 identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr 270 }); 271 272 if has_permission { 273 Ok(()) 274 } else { 275 Err(ScopeError::InsufficientScope { 276 required: format!("identity:{}", identity_attr_str(attr)), 277 message: format!( 278 "Insufficient scope to modify identity {}", 279 identity_attr_str(attr) 280 ), 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}