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 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}