Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: use tranquil-scopes instead of bespoke scope handling in delegation auth

Signed-off-by: Trezy <tre@trezy.com>

author
Trezy
committer
Tangled
date (Jul 24, 2026, 8:11 PM +0300) commit f17adc6f parent 00ca223b
+201 -112
+6
Cargo.lock
··· 7698 7698 "totp-rs", 7699 7699 "tranquil-config", 7700 7700 "tranquil-crypto", 7701 + "tranquil-types", 7701 7702 "urlencoding", 7702 7703 "uuid", 7703 7704 ] ··· 7825 7826 "thiserror 2.0.18", 7826 7827 "tokio", 7827 7828 "tracing", 7829 + "tranquil-types", 7828 7830 "unicode-segmentation", 7829 7831 "urlencoding", 7830 7832 "wiremock", ··· 8034 8036 "thiserror 2.0.18", 8035 8037 "tokio", 8036 8038 "tracing", 8039 + "tranquil-types", 8037 8040 "urlencoding", 8038 8041 ] 8039 8042 ··· 8189 8192 name = "tranquil-types" 8190 8193 version = "0.6.5" 8191 8194 dependencies = [ 8195 + "base64 0.22.1", 8192 8196 "chrono", 8193 8197 "cid", 8194 8198 "jacquard-common", 8199 + "rand 0.8.5", 8195 8200 "serde", 8196 8201 "serde_json", 8197 8202 "sqlx", 8198 8203 "thiserror 2.0.18", 8204 + "uuid", 8199 8205 ] 8200 8206 8201 8207 [[package]]
+10 -112
crates/tranquil-pds/src/delegation/scopes.rs
··· 1 1 use std::collections::HashSet; 2 2 3 + use tranquil_scopes::{covers, parse_scope}; 4 + 3 5 pub use tranquil_db_traits::{ 4 6 DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError, 5 7 }; ··· 46 48 47 49 pub fn intersect_scopes(requested: &str, granted: &str) -> String { 48 50 let requested_set: HashSet<&str> = requested.split_whitespace().collect(); 49 - let granted_set: HashSet<&str> = granted.split_whitespace().collect(); 51 + let granted_parsed: Vec<tranquil_scopes::ParsedScope> = 52 + granted.split_whitespace().map(parse_scope).collect(); 50 53 51 54 let mut scopes: Vec<&str> = requested_set 52 55 .iter() 53 56 .filter(|requested_scope| { 54 - **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set) 57 + **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed) 55 58 }) 56 59 .copied() 57 60 .chain(requested_set.contains("atproto").then_some("atproto")) ··· 60 63 scopes.join(" ") 61 64 } 62 65 63 - fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool { 64 - granted 65 - .iter() 66 - .any(|granted_scope| scope_covers(granted_scope, requested)) 67 - } 68 - 69 - fn scope_covers(granted: &str, requested: &str) -> bool { 70 - if granted == requested { 71 - return true; 72 - } 73 - 74 - let (granted_base, granted_params) = split_scope(granted); 75 - let (requested_base, requested_params) = split_scope(requested); 76 - 77 - let base_matches = if granted_base.ends_with(":*") 78 - && requested_base.starts_with(&granted_base[..granted_base.len() - 1]) 79 - { 80 - true 81 - } else if let Some(prefix) = granted_base.strip_suffix(".*") 82 - && requested_base.starts_with(prefix) 83 - && requested_base.len() > prefix.len() 84 - { 85 - true 86 - } else { 87 - granted_base == requested_base 88 - }; 89 - 90 - if !base_matches { 91 - return false; 92 - } 93 - 94 - match (granted_params, requested_params) { 95 - (None, _) => true, 96 - (Some(_), None) => true, 97 - (Some(gp), Some(rp)) => params_cover(gp, rp), 98 - } 99 - } 100 - 101 - fn params_cover(granted_params: &str, requested_params: &str) -> bool { 102 - let granted_kv: HashSet<(&str, &str)> = granted_params 103 - .split('&') 104 - .filter_map(|pair| pair.split_once('=')) 105 - .collect(); 106 - let requested_kv: HashSet<(&str, &str)> = requested_params 107 - .split('&') 108 - .filter_map(|pair| pair.split_once('=')) 109 - .collect(); 110 - 111 - let granted_keys: HashSet<&str> = granted_kv.iter().map(|(k, _)| *k).collect(); 112 - let requested_keys: HashSet<&str> = requested_kv.iter().map(|(k, _)| *k).collect(); 113 - 114 - requested_keys.iter().all(|key| { 115 - if !granted_keys.contains(key) { 116 - return false; 117 - } 118 - let requested_values: HashSet<&str> = requested_kv 119 - .iter() 120 - .filter(|(k, _)| k == key) 121 - .map(|(_, v)| *v) 122 - .collect(); 123 - let granted_values: HashSet<&str> = granted_kv 124 - .iter() 125 - .filter(|(k, _)| k == key) 126 - .map(|(_, v)| *v) 127 - .collect(); 128 - requested_values.is_subset(&granted_values) 129 - }) 130 - } 131 - 132 - fn split_scope(scope: &str) -> (&str, Option<&str>) { 133 - if let Some(idx) = scope.find('?') { 134 - (&scope[..idx], Some(&scope[idx + 1..])) 135 - } else { 136 - (scope, None) 137 - } 66 + fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool { 67 + let requested_parsed = parse_scope(requested); 68 + granted.iter().any(|g| covers(g, &requested_parsed)) 138 69 } 139 70 140 71 #[cfg(test)] ··· 280 211 } 281 212 282 213 #[test] 283 - fn test_intersect_granted_with_params_covers_requested_no_params() { 214 + fn test_intersect_partial_action_grant_drops_actionless_request() { 284 215 let result = intersect_scopes( 285 216 "repo:app.bsky.feed.post", 286 217 "repo:*?action=create&action=delete", 287 218 ); 288 - assert_eq!(result, "repo:app.bsky.feed.post"); 219 + assert_eq!(result, ""); 289 220 } 290 221 291 222 #[test] ··· 295 226 "repo:*?action=create&action=update&action=delete", 296 227 ); 297 228 assert_eq!(result, "repo:*?action=create"); 298 - } 299 - 300 - #[test] 301 - fn test_scope_covers_base_only() { 302 - assert!(scope_covers("repo:*", "repo:app.bsky.feed.post")); 303 - assert!(scope_covers( 304 - "repo:*", 305 - "repo:app.bsky.feed.post?action=create" 306 - )); 307 - assert!(!scope_covers("blob:*/*", "repo:app.bsky.feed.post")); 308 - } 309 - 310 - #[test] 311 - fn test_scope_covers_params() { 312 - assert!(scope_covers("repo:*?action=create", "repo:*?action=create")); 313 - assert!(!scope_covers( 314 - "repo:*?action=create", 315 - "repo:*?action=delete" 316 - )); 317 - assert!(scope_covers( 318 - "repo:*?action=create&action=delete", 319 - "repo:*?action=create" 320 - )); 321 - assert!(!scope_covers( 322 - "repo:*?action=create", 323 - "repo:*?action=create&action=delete" 324 - )); 325 - } 326 - 327 - #[test] 328 - fn test_scope_covers_no_granted_params_means_all() { 329 - assert!(scope_covers("repo:*", "repo:*?action=create")); 330 - assert!(scope_covers("repo:*", "repo:*?action=delete")); 331 229 } 332 230 333 231 #[test]
+183
crates/tranquil-scopes/src/coverage.rs
··· 1 + use crate::parser::{ 2 + AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, ParsedScope, 3 + RepoScope, RpcScope, 4 + }; 5 + 6 + /// Returns true if the `granted` scope authorizes everything the `requested` scope asks for. 7 + /// Typed replacement for the old string-prefix `scope_covers`. 8 + pub fn covers(granted: &ParsedScope, requested: &ParsedScope) -> bool { 9 + use ParsedScope::*; 10 + match (granted, requested) { 11 + (Atproto, Atproto) => true, 12 + (TransitionGeneric, TransitionGeneric) => true, 13 + (TransitionChat, TransitionChat) => true, 14 + (TransitionEmail, TransitionEmail) => true, 15 + (Repo(g), Repo(r)) => repo_covers(g, r), 16 + (Blob(g), Blob(r)) => blob_covers(g, r), 17 + (Rpc(g), Rpc(r)) => rpc_covers(g, r), 18 + (Account(g), Account(r)) => account_covers(g, r), 19 + (Identity(g), Identity(r)) => identity_covers(g, r), 20 + (Include(g), Include(r)) => g.nsid == r.nsid && g.aud == r.aud, 21 + (Unknown(g), Unknown(r)) => g == r, 22 + _ => false, 23 + } 24 + } 25 + 26 + fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool { 27 + let collection_ok = match &g.collection { 28 + None => true, // repo:* — any collection 29 + Some(gc) => match &r.collection { 30 + None => false, // a specific grant cannot cover a wildcard request 31 + Some(rc) => match gc.strip_suffix(".*") { 32 + Some(prefix) => rc.starts_with(prefix) && rc.as_bytes().get(prefix.len()) == Some(&b'.'), 33 + None => gc == rc, 34 + }, 35 + }, 36 + }; 37 + // parse_scope expands a missing ?action to all three actions, so subset is exact. 38 + collection_ok && r.actions.is_subset(&g.actions) 39 + } 40 + 41 + fn blob_covers(g: &BlobScope, r: &BlobScope) -> bool { 42 + if g.accept.is_empty() || g.accept.contains("*/*") { 43 + return true; 44 + } 45 + // every accept pattern the request wants must be matched by the grant 46 + !r.accept.is_empty() && r.accept.iter().all(|pat| g.matches_mime(pat)) 47 + } 48 + 49 + fn rpc_covers(g: &RpcScope, r: &RpcScope) -> bool { 50 + let lxm_ok = match &g.lxm { 51 + None => true, 52 + Some(gl) if gl == "*" => true, 53 + Some(gl) => r.lxm.as_deref() == Some(gl.as_str()), 54 + }; 55 + let aud_ok = match &g.aud { 56 + None => true, 57 + Some(ga) if ga == "*" => true, 58 + Some(ga) => r.aud.as_deref() == Some(ga.as_str()), 59 + }; 60 + lxm_ok && aud_ok 61 + } 62 + 63 + fn account_covers(g: &AccountScope, r: &AccountScope) -> bool { 64 + let attr_ok = g.attr == AccountAttr::Wildcard || g.attr == r.attr; 65 + let action_ok = match (g.action, r.action) { 66 + (AccountAction::Manage, _) => true, // manage ⊇ read 67 + (AccountAction::Read, AccountAction::Read) => true, 68 + (AccountAction::Read, AccountAction::Manage) => false, 69 + }; 70 + attr_ok && action_ok 71 + } 72 + 73 + fn identity_covers(g: &IdentityScope, r: &IdentityScope) -> bool { 74 + g.attr == IdentityAttr::Wildcard || g.attr == r.attr 75 + } 76 + 77 + #[cfg(test)] 78 + mod tests { 79 + use super::covers; 80 + use crate::parser::parse_scope; 81 + 82 + fn c(granted: &str, requested: &str) -> bool { 83 + covers(&parse_scope(granted), &parse_scope(requested)) 84 + } 85 + 86 + #[test] 87 + fn repo_wildcard_covers_specific() { 88 + assert!(c("repo:*", "repo:app.bsky.feed.post")); 89 + assert!(c("repo:*", "repo:app.bsky.feed.post?action=create")); 90 + } 91 + 92 + #[test] 93 + fn repo_specific_does_not_cover_wildcard() { 94 + assert!(!c("repo:app.bsky.feed.post", "repo:*")); 95 + } 96 + 97 + #[test] 98 + fn repo_action_subset_required() { 99 + // granted no ?action == all three actions 100 + assert!(c("repo:*", "repo:*?action=create")); 101 + assert!(!c("repo:*?action=create", "repo:*?action=delete")); 102 + assert!(c("repo:*?action=create&action=delete", "repo:*?action=create")); 103 + assert!(!c("repo:*?action=create", "repo:*?action=create&action=delete")); 104 + } 105 + 106 + #[test] 107 + fn repo_partial_action_grant_does_not_cover_actionless_request() { 108 + // SECURITY: actionless requested repo == all three actions; a create+delete 109 + // grant must NOT cover it (previously the string engine wrongly did). 110 + assert!(!c("repo:*?action=create&action=delete", "repo:app.bsky.feed.post")); 111 + } 112 + 113 + #[test] 114 + fn repo_collection_prefix_wildcard() { 115 + assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); 116 + assert!(!c("repo:app.bsky.*", "repo:app.bsky")); 117 + assert!(!c("repo:app.bsky.*", "repo:com.example.foo")); 118 + } 119 + 120 + #[test] 121 + fn blob_wildcard_covers_all() { 122 + assert!(c("blob:*/*", "blob:image/png")); 123 + assert!(c("blob:*/*", "blob:*/*")); 124 + } 125 + 126 + #[test] 127 + fn blob_type_prefix() { 128 + assert!(c("blob:image/*", "blob:image/png")); 129 + assert!(!c("blob:image/*", "blob:video/mp4")); 130 + } 131 + 132 + #[test] 133 + fn rpc_wildcards() { 134 + assert!(c("rpc:*?aud=did:web:x", "rpc:app.bsky.getX?aud=did:web:x")); 135 + assert!(c("rpc:app.bsky.getX?aud=*", "rpc:app.bsky.getX?aud=did:web:x")); 136 + assert!(!c("rpc:app.bsky.getX?aud=did:web:x", "rpc:app.bsky.getY?aud=did:web:x")); 137 + } 138 + 139 + #[test] 140 + fn account_manage_covers_read_and_wildcard_attr() { 141 + assert!(c("account:*?action=manage", "account:email?action=read")); 142 + assert!(c("account:*?action=manage", "account:email?action=manage")); 143 + assert!(!c("account:email?action=read", "account:email?action=manage")); 144 + } 145 + 146 + #[test] 147 + fn identity_wildcard_covers_handle() { 148 + assert!(c("identity:*", "identity:handle")); 149 + assert!(!c("identity:handle", "identity:*")); 150 + } 151 + 152 + #[test] 153 + fn cross_type_never_covers() { 154 + assert!(!c("repo:*", "blob:*/*")); 155 + assert!(!c("blob:*/*", "repo:app.bsky.feed.post")); 156 + } 157 + 158 + #[test] 159 + fn atproto_and_transition_self_cover() { 160 + assert!(c("atproto", "atproto")); 161 + assert!(c("transition:generic", "transition:generic")); 162 + assert!(!c("transition:generic", "atproto")); 163 + } 164 + 165 + #[test] 166 + fn repo_prefix_wildcard_respects_dot_boundary() { 167 + assert!(!c("repo:app.bsky.*", "repo:app.bskyEXTRA")); 168 + assert!(c("repo:app.bsky.*", "repo:app.bsky.feed.post")); 169 + assert!(!c("repo:app.bsky.*", "repo:app.bsky")); // no trailing segment 170 + } 171 + 172 + #[test] 173 + fn include_exact_match_only() { 174 + assert!(c("include:io.x.set", "include:io.x.set")); 175 + assert!(!c("include:io.x.set", "include:io.y.set")); 176 + } 177 + 178 + #[test] 179 + fn unknown_exact_match_only() { 180 + assert!(c("weird:token", "weird:token")); 181 + assert!(!c("weird:token", "other:token")); 182 + } 183 + }
+2
crates/tranquil-scopes/src/lib.rs
··· 1 + mod coverage; 1 2 mod definitions; 2 3 mod error; 3 4 mod parser; 4 5 mod permission_set; 5 6 mod permissions; 6 7 8 + pub use coverage::covers; 7 9 pub use definitions::{ 8 10 SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition, format_scope_for_display, 9 11 get_required_scopes, get_scope_definition, is_valid_scope,