this repo has no description
1use reqwest::Client;
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::sync::LazyLock;
5use tokio::sync::RwLock;
6use tracing::{debug, warn};
7
8static LEXICON_CACHE: LazyLock<RwLock<HashMap<String, CachedLexicon>>> =
9 LazyLock::new(|| RwLock::new(HashMap::new()));
10
11#[derive(Clone)]
12struct CachedLexicon {
13 expanded_scope: String,
14 cached_at: std::time::Instant,
15}
16
17const CACHE_TTL_SECS: u64 = 3600;
18
19#[derive(Debug, Deserialize)]
20struct LexiconDoc {
21 defs: HashMap<String, LexiconDef>,
22}
23
24#[derive(Debug, Deserialize)]
25struct LexiconDef {
26 #[serde(rename = "type")]
27 def_type: String,
28 permissions: Option<Vec<PermissionEntry>>,
29}
30
31#[derive(Debug, Deserialize)]
32struct PermissionEntry {
33 resource: String,
34 collection: Option<Vec<String>>,
35}
36
37pub async fn expand_include_scopes(scope_string: &str) -> String {
38 let futures: Vec<_> = scope_string
39 .split_whitespace()
40 .map(|scope| async move {
41 match scope.strip_prefix("include:") {
42 Some(nsid) => {
43 let nsid_base = nsid.split('?').next().unwrap_or(nsid);
44 expand_permission_set(nsid_base).await.unwrap_or_else(|e| {
45 warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original");
46 scope.to_string()
47 })
48 }
49 None => scope.to_string(),
50 }
51 })
52 .collect();
53
54 futures::future::join_all(futures).await.join(" ")
55}
56
57async fn expand_permission_set(nsid: &str) -> Result<String, String> {
58 {
59 let cache = LEXICON_CACHE.read().await;
60 if let Some(cached) = cache.get(nsid)
61 && cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS
62 {
63 debug!(nsid, "Using cached permission set expansion");
64 return Ok(cached.expanded_scope.clone());
65 }
66 }
67
68 let parts: Vec<&str> = nsid.split('.').collect();
69 if parts.len() < 3 {
70 return Err(format!("Invalid NSID format: {}", nsid));
71 }
72
73 let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
74 let domain = domain_parts.join(".");
75 let path = parts[2..].join("/");
76
77 let url = format!("https://{}/lexicons/{}.json", domain, path);
78 debug!(nsid, url = %url, "Fetching permission set lexicon");
79
80 let client = Client::builder()
81 .timeout(std::time::Duration::from_secs(10))
82 .build()
83 .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
84
85 let response = client
86 .get(&url)
87 .header("Accept", "application/json")
88 .send()
89 .await
90 .map_err(|e| format!("Failed to fetch lexicon: {}", e))?;
91
92 if !response.status().is_success() {
93 return Err(format!(
94 "Failed to fetch lexicon: HTTP {}",
95 response.status()
96 ));
97 }
98
99 let lexicon: LexiconDoc = response
100 .json()
101 .await
102 .map_err(|e| format!("Failed to parse lexicon: {}", e))?;
103
104 let main_def = lexicon
105 .defs
106 .get("main")
107 .ok_or("Missing 'main' definition in lexicon")?;
108
109 if main_def.def_type != "permission-set" {
110 return Err(format!(
111 "Expected permission-set type, got: {}",
112 main_def.def_type
113 ));
114 }
115
116 let permissions = main_def
117 .permissions
118 .as_ref()
119 .ok_or("Missing permissions in permission-set")?;
120
121 let mut collections: Vec<String> = permissions
122 .iter()
123 .filter(|perm| perm.resource == "repo")
124 .filter_map(|perm| perm.collection.as_ref())
125 .flatten()
126 .cloned()
127 .collect();
128
129 if collections.is_empty() {
130 return Err("No repo collections found in permission-set".to_string());
131 }
132
133 collections.sort();
134
135 let collection_params: Vec<String> = collections
136 .iter()
137 .map(|c| format!("collection={}", c))
138 .collect();
139
140 let expanded = format!("repo?{}", collection_params.join("&"));
141
142 {
143 let mut cache = LEXICON_CACHE.write().await;
144 cache.insert(
145 nsid.to_string(),
146 CachedLexicon {
147 expanded_scope: expanded.clone(),
148 cached_at: std::time::Instant::now(),
149 },
150 );
151 }
152
153 debug!(nsid, expanded = %expanded, "Successfully expanded permission set");
154 Ok(expanded)
155}
156
157#[cfg(test)]
158mod tests {
159 #[test]
160 fn test_nsid_to_url() {
161 let nsid = "io.atcr.authFullApp";
162 let parts: Vec<&str> = nsid.split('.').collect();
163 let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
164 let domain = domain_parts.join(".");
165 let path = parts[2..].join("/");
166
167 assert_eq!(domain, "atcr.io");
168 assert_eq!(path, "authFullApp");
169 }
170}