1use std::collections::BTreeSet;
24
25use didbot_lexicon::nsid_syntax;
26use serde_json::{Map, Value};
27
28use crate::scope::ANY_AUD;
29use crate::{Action, ActionSet, NsidPattern, Scope};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Include {
35 nsid: String,
36 aud: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum IncludeError {
42 #[error("`{0}` is not an NSID")]
44 NotAnNsid(String),
45 #[error("`{0}` is not a DID with a service fragment")]
47 NotAServiceReference(String),
48}
49
50impl Include {
51 pub fn new(nsid: &str, aud: Option<&str>) -> Result<Self, IncludeError> {
57 if nsid_syntax::validate(nsid).is_err() {
58 return Err(IncludeError::NotAnNsid(nsid.to_owned()));
59 }
60 if let Some(aud) = aud.filter(|aud| !is_service_reference(aud)) {
61 return Err(IncludeError::NotAServiceReference(aud.to_owned()));
62 }
63 Ok(Self {
64 nsid: nsid.to_owned(),
65 aud: aud.map(str::to_owned),
66 })
67 }
68
69 pub fn nsid(&self) -> &str {
71 &self.nsid
72 }
73
74 pub fn aud(&self) -> Option<&str> {
76 self.aud.as_deref()
77 }
78
79 pub fn grants(&self, permissions: &[Value]) -> Vec<Scope> {
82 permissions
83 .iter()
84 .filter_map(|permission| self.grant(permission.as_object()?))
85 .flatten()
86 .collect()
87 }
88
89 fn grant(&self, permission: &Map<String, Value>) -> Option<Vec<Scope>> {
91 if permission.get("type")?.as_str()? != "permission" {
92 return None;
93 }
94 if permission
95 .get("description")
96 .is_some_and(|description| !description.is_string())
97 {
98 return None;
99 }
100 match permission.get("resource")?.as_str()? {
101 "repo" => self.repo(permission),
102 "rpc" => self.rpc(permission),
103 _ => None,
104 }
105 }
106
107 fn repo(&self, permission: &Map<String, Value>) -> Option<Vec<Scope>> {
108 has_only(permission, &["collection", "action"])?;
109 let collections = self.nsids(permission.get("collection")?, true)?;
110 let actions = match permission.get("action") {
111 None => ActionSet::All,
112 Some(actions) => ActionSet::Only(repo_actions(actions)?),
113 };
114 Some(
115 collections
116 .into_iter()
117 .map(|collection| Scope::Repo {
118 collection: NsidPattern::Exact(collection),
119 actions: actions.clone(),
120 })
121 .collect(),
122 )
123 }
124
125 fn rpc(&self, permission: &Map<String, Value>) -> Option<Vec<Scope>> {
126 has_only(permission, &["lxm", "aud", "inheritAud"])?;
127 let methods = self.nsids(permission.get("lxm")?, false)?;
128 let inherits = match permission.get("inheritAud") {
129 None => false,
130 Some(inherits) => inherits.as_bool()?,
131 };
132 let aud = match (inherits, permission.get("aud")) {
133 (true, None) => self.aud.clone()?,
134 (false, Some(aud)) if aud.as_str() == Some(ANY_AUD) => ANY_AUD.to_owned(),
135 _ => return None,
136 };
137 Some(
138 methods
139 .into_iter()
140 .map(|method| Scope::Rpc {
141 method: NsidPattern::Exact(method),
142 aud: Some(aud.clone()),
143 })
144 .collect(),
145 )
146 }
147
148 fn nsids(&self, list: &Value, unique: bool) -> Option<BTreeSet<String>> {
151 let list = list.as_array().filter(|list| !list.is_empty())?;
152 let mut nsids = BTreeSet::new();
153 for nsid in list {
154 let nsid = nsid.as_str()?;
155 if nsid_syntax::validate(nsid).is_err() || !self.authorizes(nsid) {
156 return None;
157 }
158 if !nsids.insert(nsid.to_owned()) && unique {
159 return None;
160 }
161 }
162 Some(nsids)
163 }
164
165 fn authorizes(&self, nsid: &str) -> bool {
168 let Some((group, _)) = self.nsid.rsplit_once('.') else {
169 return false;
170 };
171 nsid.strip_prefix(group)
172 .and_then(|rest| rest.strip_prefix('.'))
173 .is_some_and(|rest| !rest.is_empty())
174 }
175}
176
177fn has_only(permission: &Map<String, Value>, fields: &[&str]) -> Option<()> {
180 permission
181 .keys()
182 .all(|key| {
183 matches!(key.as_str(), "type" | "resource" | "description")
184 || fields.contains(&key.as_str())
185 })
186 .then_some(())
187}
188
189fn repo_actions(list: &Value) -> Option<BTreeSet<Action>> {
191 let list = list.as_array().filter(|list| !list.is_empty())?;
192 let mut actions = BTreeSet::new();
193 for action in list {
194 let action = match action.as_str()? {
195 "create" => Action::Create,
196 "update" => Action::Update,
197 "delete" => Action::Delete,
198 _ => return None,
199 };
200 if !actions.insert(action) {
201 return None;
202 }
203 }
204 Some(actions)
205}
206
207pub(crate) fn is_service_reference(aud: &str) -> bool {
210 let Some((did, fragment)) = aud.split_once('#') else {
211 return false;
212 };
213 (did.starts_with("did:plc:") || did.starts_with("did:web:"))
214 && didbot_identity::validate_did(did).is_ok()
215 && is_fragment(fragment)
216}
217
218fn is_fragment(fragment: &str) -> bool {
221 let bytes = fragment.as_bytes();
222 let mut at = 0;
223 while at < bytes.len() {
224 match bytes[at] {
225 b'%' if bytes
226 .get(at + 1..at + 3)
227 .is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit)) =>
228 {
229 at += 3;
230 }
231 byte if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/?".contains(&byte) => {
232 at += 1;
233 }
234 _ => return false,
235 }
236 }
237 !bytes.is_empty()
238}
239
240#[cfg(test)]
241mod tests {
242 use serde_json::json;
243
244 use super::*;
245 use crate::ScopeSet;
246
247 const APPVIEW: &str = "did:web:api.example.com#svc_appview";
248
249 fn read(name: &str) -> Result<Include, IncludeError> {
251 let Scope::Include { nsid, aud } = Scope::parse(&format!("include:{name}")).unwrap() else {
252 panic!("`include:{name}` is not an include");
253 };
254 Include::new(&nsid, aud.as_deref())
255 }
256
257 fn include(name: &str) -> Include {
258 read(name).unwrap_or_else(|error| panic!("`{name}`: {error}"))
259 }
260
261 fn granted(name: &str, permissions: Value) -> String {
263 let permissions = permissions.as_array().expect("a list").clone();
264 ScopeSet::new(include(name).grants(&permissions)).to_string()
265 }
266
267 #[test]
268 fn a_set_grants_each_collection_and_method_it_names() {
269 let permissions = json!([
270 {
271 "type": "permission",
272 "resource": "repo",
273 "collection": ["com.example.post", "com.example.feed.like"],
274 "action": ["create", "delete"],
275 },
276 {
277 "type": "permission",
278 "resource": "repo",
279 "collection": ["com.example.profile"],
280 },
281 {
282 "type": "permission",
283 "resource": "rpc",
284 "lxm": ["com.example.getFeedSkeleton"],
285 "aud": "*",
286 },
287 ]);
288 assert_eq!(
289 granted("com.example.authFull", permissions),
290 "repo:com.example.feed.like?action=create&action=delete \
291 repo:com.example.post?action=create&action=delete \
292 repo:com.example.profile \
293 rpc:com.example.getFeedSkeleton?aud=*"
294 );
295 }
296
297 #[test]
301 fn the_include_aud_reaches_the_rpc_permissions_that_inherit_it() {
302 let permissions = json!([{
303 "type": "permission",
304 "resource": "rpc",
305 "inheritAud": true,
306 "lxm": ["com.example.getFeed", "com.example.getProfile"],
307 }]);
308 let name = "com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview";
309 assert_eq!(include(name).aud(), Some(APPVIEW));
310 let grant = granted(name, permissions);
311 assert_eq!(
312 grant,
313 "rpc:com.example.getFeed?aud=did:web:api.example.com%23svc_appview \
314 rpc:com.example.getProfile?aud=did:web:api.example.com%23svc_appview"
315 );
316 assert!(ScopeSet::parse(&grant)
317 .expect("a grant parses")
318 .permits_rpc(Some("com.example.getFeed"), APPVIEW));
319 }
320
321 #[test]
325 fn inherit_aud_grants_nothing_without_one_audience_to_take() {
326 let inherits = json!([{
327 "type": "permission",
328 "resource": "rpc",
329 "inheritAud": true,
330 "lxm": ["com.example.getFeed"],
331 }]);
332 assert_eq!(granted("com.example.authFull", inherits), "");
333
334 let both = json!([{
335 "type": "permission",
336 "resource": "rpc",
337 "inheritAud": true,
338 "aud": "*",
339 "lxm": ["com.example.getFeed"],
340 }]);
341 assert_eq!(
342 granted(
343 "com.example.authFull?aud=did:web:api.example.com%23svc_appview",
344 both
345 ),
346 ""
347 );
348 }
349
350 #[test]
354 fn a_set_grants_only_under_its_own_group() {
355 let repo = |collection: &str| {
356 json!([{
357 "type": "permission",
358 "resource": "repo",
359 "collection": [collection],
360 }])
361 };
362 for (collection, granted_it) in [
363 ("app.example.feed.post", true),
364 ("app.example.feed.thread.gate", true),
365 ("app.example.feed", false),
366 ("app.example.actor.profile", false),
367 ("app.example.feedx.post", false),
368 ("app.other.feed.post", false),
369 ("*", false),
370 ("app.example.feed.*", false),
371 ] {
372 let grant = granted("app.example.feed.authOnlyPost", repo(collection));
373 assert_eq!(!grant.is_empty(), granted_it, "`{collection}`: `{grant}`");
374 }
375 }
376
377 #[test]
382 fn a_permission_this_cannot_read_is_ignored_whole() {
383 let fine = json!({
384 "type": "permission",
385 "resource": "repo",
386 "collection": ["com.example.post"],
387 });
388 for ignored in [
389 json!({"type": "permission", "resource": "blob", "accept": ["image/*"]}),
390 json!({"type": "permission", "resource": "account", "attr": "email"}),
391 json!({"type": "permission", "resource": "identity", "attr": "handle"}),
392 json!({"type": "permission", "resource": "scry", "collection": ["com.example.x"]}),
393 json!({"type": "token", "resource": "repo", "collection": ["com.example.x"]}),
394 json!({"resource": "repo", "collection": ["com.example.x"]}),
395 json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
396 "maxRecords": 1}),
397 json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
398 "action": ["manage"]}),
399 json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
400 "action": ["create", "create"]}),
401 json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
402 "action": []}),
403 json!({"type": "permission", "resource": "repo",
404 "collection": ["com.example.x", "com.example.x"]}),
405 json!({"type": "permission", "resource": "repo", "collection": []}),
406 json!({"type": "permission", "resource": "repo", "collection": "com.example.x"}),
407 json!({"type": "permission", "resource": "repo",
408 "collection": ["com.example.x", "com.other.x"]}),
409 json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
410 "description": 7}),
411 json!({"type": "permission", "resource": "rpc", "lxm": ["com.example.get"],
412 "aud": APPVIEW}),
413 json!({"type": "permission", "resource": "rpc", "lxm": ["com.example.get"]}),
414 json!({"type": "permission", "resource": "rpc", "lxm": ["com.example.get"],
415 "inheritAud": "yes"}),
416 json!({"type": "permission", "resource": "rpc", "lxm": ["*"], "aud": "*"}),
417 json!("repo:com.example.x"),
418 ] {
419 assert_eq!(
420 granted(
421 "com.example.authFull",
422 json!([ignored.clone(), fine.clone()])
423 ),
424 "repo:com.example.post",
425 "{ignored}"
426 );
427 }
428 }
429
430 #[test]
433 fn a_description_and_a_false_inherit_aud_are_read() {
434 let permissions = json!([
435 {
436 "type": "permission",
437 "resource": "repo",
438 "collection": ["com.example.post"],
439 "description": "Posts.",
440 },
441 {
442 "type": "permission",
443 "resource": "rpc",
444 "lxm": ["com.example.getFeed"],
445 "aud": "*",
446 "inheritAud": false,
447 },
448 ]);
449 assert_eq!(
450 granted("com.example.authFull", permissions),
451 "repo:com.example.post rpc:com.example.getFeed?aud=*"
452 );
453 }
454
455 #[test]
456 fn an_include_names_a_set_by_its_nsid_and_one_service() {
457 assert_eq!(
458 include("com.example.authFull").nsid(),
459 "com.example.authFull"
460 );
461 assert_eq!(include("com.example.authFull").aud(), None);
462 assert_eq!(
463 include("com.example.authFull?aud=did:web:api.example.com#svc_appview").aud(),
464 Some(APPVIEW)
465 );
466 assert_eq!(
467 include("com.example.authFull?aud=did:plc:ewvi7nxzyoun6zhxrhs64oiz%23atproto_labeler")
468 .aud(),
469 Some("did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto_labeler")
470 );
471 for refused in [
472 "authFull",
473 "com.example",
474 "com.example.*",
475 "com.example.authFull?aud=*",
476 "com.example.authFull?aud=did:web:api.example.com",
477 "com.example.authFull?aud=did:web:api.example.com%23",
478 "com.example.authFull?aud=did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme%23x",
479 "com.example.authFull?aud=did:web:a.example%23x%23y",
480 ] {
481 assert!(read(refused).is_err(), "`include:{refused}` names a set");
482 }
483 for (spelled, aud) in [
485 (
486 "com.example.authFull?aud=did:web:a.example%23x&aud=did:web:b.example%23x",
487 Some("did:web:a.example#x"),
488 ),
489 ("com.example.authFull?nsid=com.example.other", None),
490 ("com.example.authFull?lang=en", None),
491 ("?nsid=com.example.authFull", None),
492 ] {
493 let read = include(spelled);
494 assert_eq!(read.nsid(), "com.example.authFull", "`include:{spelled}`");
495 assert_eq!(read.aud(), aud, "`include:{spelled}`");
496 }
497 for unread in [
498 "include:?aud=did:web:api.example.com%23svc",
499 "include:com.example.authFull?aud",
500 ] {
501 assert!(
502 Scope::parse(unread).is_err(),
503 "`{unread}` read as an include"
504 );
505 }
506 }
507}