1use crate::path;
2use moq_pattern::Patterns;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
19#[serde(try_from = "crate::wire::Scope", into = "crate::wire::Scope")]
20pub struct Scope {
21 pub root: String,
23
24 pub publish: Patterns,
26
27 pub subscribe: Patterns,
29}
30
31impl Scope {
32 pub fn validate(&self) -> crate::Result<()> {
34 if self.publish.is_empty() && self.subscribe.is_empty() {
35 return Err(crate::Error::UselessScope);
36 }
37
38 Ok(())
39 }
40
41 pub fn allows(&self, claims: &Claims) -> bool {
52 let covers = |granted: &Patterns, requested: &Patterns| {
53 match (granted.rooted(&self.root), requested.rooted(&claims.root)) {
54 (Ok(granted), Ok(requested)) => granted.covers(&requested),
55 _ => false,
57 }
58 };
59
60 covers(&self.publish, &claims.publish) && covers(&self.subscribe, &claims.subscribe)
61 }
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct Permissions {
72 pub subscribe: Patterns,
74
75 pub publish: Patterns,
77}
78
79impl Permissions {
80 pub fn new(publish: Patterns, subscribe: Patterns) -> Self {
82 Self { publish, subscribe }
83 }
84
85 pub fn is_empty(&self) -> bool {
87 self.publish.is_empty() && self.subscribe.is_empty()
88 }
89}
90
91#[derive(Debug, Serialize, Deserialize, Default, Clone)]
112#[serde(try_from = "crate::wire::Claims", into = "crate::wire::Claims")]
113#[non_exhaustive]
114pub struct Claims {
115 pub root: String,
118
119 pub publish: Patterns,
122
123 pub subscribe: Patterns,
126
127 pub expires: Option<std::time::SystemTime>,
129
130 pub issued: Option<std::time::SystemTime>,
132
133 pub not_before: Option<std::time::SystemTime>,
136}
137
138impl Claims {
139 pub fn with_root(mut self, root: impl Into<String>) -> Self {
141 self.root = root.into();
142 self
143 }
144
145 pub fn with_publish(mut self, patterns: impl IntoIterator<Item = moq_pattern::Pattern>) -> Self {
147 self.publish = patterns.into_iter().collect();
148 self
149 }
150
151 pub fn with_subscribe(mut self, patterns: impl IntoIterator<Item = moq_pattern::Pattern>) -> Self {
153 self.subscribe = patterns.into_iter().collect();
154 self
155 }
156
157 pub fn with_expires(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
161 self.expires = at.into();
162 self
163 }
164
165 pub fn with_issued(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
169 self.issued = at.into();
170 self
171 }
172
173 pub fn validate(&self) -> crate::Result<()> {
175 if self.publish.is_empty() && self.subscribe.is_empty() {
176 return Err(crate::Error::UselessToken);
177 }
178
179 Ok(())
180 }
181
182 pub fn authorize(&self, path: &str) -> crate::Result<Permissions> {
202 let path = path::normalize(path);
203 let root = path::normalize(&self.root);
204
205 let (suffix, prefix) = if let Some(suffix) = path::strip_prefix(&path, &root) {
208 (suffix, "")
209 } else if let Some(prefix) = path::strip_prefix(&root, &path) {
210 ("", prefix)
211 } else {
212 return Err(crate::Error::RootMismatch(path));
213 };
214
215 let scope = |patterns: &Patterns| -> crate::Result<Patterns> {
216 if prefix.is_empty() {
217 Ok(patterns.rebase(suffix))
219 } else {
220 Ok(patterns.rooted(prefix)?)
222 }
223 };
224
225 let permissions = Permissions {
226 subscribe: scope(&self.subscribe)?,
227 publish: scope(&self.publish)?,
228 };
229
230 if permissions.subscribe.is_empty() && permissions.publish.is_empty() {
231 return Err(crate::Error::NoAccess(path));
232 }
233
234 Ok(permissions)
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 use std::time::{Duration, SystemTime};
243
244 fn patterns(texts: &[&str]) -> Patterns {
245 texts.iter().map(|text| text.parse().unwrap()).collect()
246 }
247
248 fn create_test_claims() -> Claims {
249 Claims {
250 root: "test-path".to_string(),
251 publish: patterns(&["test-pub/**"]),
252 subscribe: patterns(&["test-sub/**"]),
253 expires: Some(SystemTime::now() + Duration::from_secs(3600)),
254 issued: Some(SystemTime::now()),
255 not_before: None,
256 }
257 }
258
259 #[test]
260 fn scope_allows_contained_claims() {
261 let scope = Scope {
262 root: "project".into(),
263 publish: patterns(&["live/**"]),
264 subscribe: patterns(&["watch/**"]),
265 };
266 let claims = Claims {
267 root: "project/live/room".into(),
268 publish: patterns(&["**"]),
269 ..Default::default()
270 };
271 assert!(scope.allows(&claims));
272 }
273
274 #[test]
275 fn scope_rejects_sibling_and_role_escalation() {
276 let scope = Scope {
277 root: "project".into(),
278 publish: patterns(&["live/**"]),
279 subscribe: Patterns::new(),
280 };
281 let sibling = Claims {
282 root: "project/lively".into(),
283 publish: patterns(&["**"]),
284 ..Default::default()
285 };
286 let role = Claims {
287 root: "project/live".into(),
288 subscribe: patterns(&["**"]),
289 ..Default::default()
290 };
291 assert!(!scope.allows(&sibling));
292 assert!(!scope.allows(&role));
293 }
294
295 #[test]
296 fn scope_ignores_how_the_root_is_split() {
297 let scope = Scope {
299 root: "project".into(),
300 publish: patterns(&["live/**"]),
301 subscribe: Patterns::new(),
302 };
303
304 for claims in [
305 Claims {
306 root: "project".into(),
307 publish: patterns(&["live/room/**"]),
308 ..Default::default()
309 },
310 Claims {
311 root: String::new(),
312 publish: patterns(&["project/live/room/**"]),
313 ..Default::default()
314 },
315 Claims {
316 root: "/project/live/".into(),
317 publish: patterns(&["room/**"]),
318 ..Default::default()
319 },
320 ] {
321 assert!(scope.allows(&claims), "{claims:?}");
322 }
323 }
324
325 #[test]
326 fn scope_rejects_escaping_above_its_root() {
327 let scope = Scope {
328 root: "project".into(),
329 publish: patterns(&["live/**"]),
330 subscribe: Patterns::new(),
331 };
332
333 let claims = Claims {
336 root: String::new(),
337 publish: patterns(&["**"]),
338 ..Default::default()
339 };
340 assert!(!scope.allows(&claims));
341 }
342
343 #[test]
344 fn scope_globstar_grants_everything_beneath_it() {
345 let scope = Scope {
346 root: "project".into(),
347 publish: patterns(&["**"]),
348 subscribe: Patterns::new(),
349 };
350 let claims = Claims {
351 root: "project/anything/deep".into(),
352 publish: patterns(&["**"]),
353 ..Default::default()
354 };
355 assert!(scope.allows(&claims));
356 }
357
358 #[test]
359 fn scope_requires_every_requested_pattern() {
360 let scope = Scope {
362 root: "project".into(),
363 publish: patterns(&["live/**"]),
364 subscribe: Patterns::new(),
365 };
366 let claims = Claims {
367 root: "project".into(),
368 publish: patterns(&["live/room/**", "other/**"]),
369 ..Default::default()
370 };
371 assert!(!scope.allows(&claims));
372 }
373
374 #[test]
375 fn scope_is_exact_about_a_literal() {
376 let scope = Scope {
378 root: "project".into(),
379 publish: patterns(&["live"]),
380 subscribe: Patterns::new(),
381 };
382 let exact = Claims {
383 root: "project".into(),
384 publish: patterns(&["live"]),
385 ..Default::default()
386 };
387 let subtree = Claims {
388 root: "project".into(),
389 publish: patterns(&["live/**"]),
390 ..Default::default()
391 };
392 assert!(scope.allows(&exact));
393 assert!(!scope.allows(&subtree));
394 }
395
396 #[test]
397 fn scope_without_grants_is_useless() {
398 assert!(matches!(Scope::default().validate(), Err(crate::Error::UselessScope)));
399 }
400
401 #[test]
402 fn scope_refuses_null_grants() {
403 assert!(serde_json::from_str::<Scope>(r#"{"put":null,"publish":["room"]}"#).is_err());
404 }
405
406 #[test]
407 fn scope_reads_legacy_prefixes_as_subtrees() {
408 let scope: Scope = serde_json::from_str(r#"{"root":"demo","put":["room"],"get":[""]}"#).unwrap();
409 assert_eq!(scope.publish, patterns(&["room/**"]));
410 assert_eq!(scope.subscribe, patterns(&["**"]));
411 }
412
413 #[test]
414 fn scope_writes_legacy_prefixes_only_when_faithful() {
415 let subtrees = Scope {
416 root: "demo".into(),
417 publish: patterns(&["room/**"]),
418 subscribe: patterns(&["**"]),
419 };
420 assert_eq!(
421 serde_json::to_string(&subtrees).unwrap(),
422 r#"{"root":"demo","put":["room"],"get":[""]}"#
423 );
424
425 let exact = Scope {
426 root: "demo".into(),
427 publish: patterns(&["room"]),
428 subscribe: Patterns::new(),
429 };
430 assert_eq!(
431 serde_json::to_string(&exact).unwrap(),
432 r#"{"root":"demo","publish":["room"]}"#
433 );
434 }
435
436 #[test]
437 fn test_claims_validation_success() {
438 let claims = create_test_claims();
439 assert!(claims.validate().is_ok());
440 }
441
442 #[test]
443 fn test_claims_validation_no_publish_or_subscribe() {
444 let claims = Claims {
445 root: "test-path".to_string(),
446 ..Default::default()
447 };
448
449 let result = claims.validate();
450 assert!(result.is_err());
451 assert!(
452 result
453 .unwrap_err()
454 .to_string()
455 .contains("no publish or subscribe allowed; token is useless")
456 );
457 }
458
459 #[test]
460 fn test_claims_validation_only_publish() {
461 let claims = Claims {
462 root: "test-path".to_string(),
463 publish: patterns(&["test-pub"]),
464 ..Default::default()
465 };
466
467 assert!(claims.validate().is_ok());
468 }
469
470 #[test]
471 fn test_claims_validation_only_subscribe() {
472 let claims = Claims {
473 root: "test-path".to_string(),
474 subscribe: patterns(&["test-sub"]),
475 ..Default::default()
476 };
477
478 assert!(claims.validate().is_ok());
479 }
480
481 #[test]
482 fn test_claims_serde() {
483 let claims = create_test_claims();
484 let json = serde_json::to_string(&claims).unwrap();
485 let deserialized: Claims = serde_json::from_str(&json).unwrap();
486
487 assert_eq!(deserialized.root, claims.root);
488 assert_eq!(deserialized.publish, claims.publish);
489 assert_eq!(deserialized.subscribe, claims.subscribe);
490 }
491
492 #[test]
493 fn test_claims_serde_names() {
494 let claims = Claims {
495 root: "live".into(),
496 publish: patterns(&["camera1"]),
497 subscribe: patterns(&["camera1", "camera2"]),
498 ..Default::default()
499 };
500 assert_eq!(
501 serde_json::to_string(&claims).unwrap(),
502 r#"{"root":"live","publish":["camera1"],"subscribe":["camera1","camera2"]}"#
503 );
504 }
505
506 #[test]
507 fn test_claims_read_legacy_prefixes_as_subtrees() {
508 let claims: Claims =
509 serde_json::from_str(r#"{"root":"test","put":["pub1","/a//b/"],"get":"","exp":1700000000}"#).unwrap();
510 assert_eq!(claims.publish, patterns(&["pub1/**", "a/b/**"]));
511 assert_eq!(claims.subscribe, patterns(&["**"]));
512 assert!(claims.expires.is_some());
513 }
514
515 #[test]
516 fn test_claims_write_legacy_prefixes_only_when_faithful() {
517 let subtrees = Claims {
519 root: "live".into(),
520 publish: patterns(&["camera1/**"]),
521 subscribe: patterns(&["**"]),
522 ..Default::default()
523 };
524 let json = serde_json::to_string(&subtrees).unwrap();
525 assert_eq!(json, r#"{"root":"live","put":["camera1"],"get":[""]}"#);
526 let back: Claims = serde_json::from_str(&json).unwrap();
527 assert_eq!(back.publish, subtrees.publish);
528 assert_eq!(back.subscribe, subtrees.subscribe);
529
530 let mixed = Claims {
532 root: "live".into(),
533 publish: patterns(&["camera1/**"]),
534 subscribe: patterns(&["*/chat"]),
535 ..Default::default()
536 };
537 assert_eq!(
538 serde_json::to_string(&mixed).unwrap(),
539 r#"{"root":"live","publish":["camera1/**"],"subscribe":["*/chat"]}"#
540 );
541 }
542
543 #[test]
544 fn test_claims_refuse_mixed_or_unknown_fields() {
545 for json in [
546 r#"{"root":"test","publish":["pub1"],"get":["sub1"]}"#,
547 r#"{"root":"test","put":[],"subscribe":["sub1"]}"#,
548 r#"{"root":"test","put":["pub1"],"cluster":true}"#,
549 r#"{"root":"test","put":null,"publish":["pub1"]}"#,
550 r#"{"root":"test","publish":null,"subscribe":["sub1"]}"#,
551 ] {
552 assert!(serde_json::from_str::<Claims>(json).is_err(), "{json}");
553 }
554 }
555
556 #[test]
557 fn test_claims_refuse_a_wildcard_in_a_legacy_prefix() {
558 assert!(serde_json::from_str::<Claims>(r#"{"put":["a/*"]}"#).is_err());
560 }
561
562 #[test]
563 fn test_claims_refuse_a_bad_pattern() {
564 let err = serde_json::from_str::<Claims>(r#"{"publish":["a/**/b/**"]}"#).unwrap_err();
565 assert!(err.to_string().contains("**"), "{err}");
566 }
567
568 #[test]
569 fn test_claims_default() {
570 let claims = Claims::default();
571 assert_eq!(claims.root, "");
572 assert!(claims.publish.is_empty());
573 assert!(claims.subscribe.is_empty());
574 assert_eq!(claims.expires, None);
575 assert_eq!(claims.issued, None);
576 }
577
578 fn authorize_claims(root: &str, subscribe: &[&str], publish: &[&str]) -> Claims {
579 Claims {
580 root: root.to_string(),
581 subscribe: patterns(subscribe),
582 publish: patterns(publish),
583 ..Default::default()
584 }
585 }
586
587 #[test]
588 fn test_authorize_path_equals_root() {
589 let claims = authorize_claims("room/123", &["**"], &["alice/**"]);
590 let permissions = claims.authorize("room/123").unwrap();
591
592 assert_eq!(permissions.subscribe, patterns(&["**"]));
593 assert_eq!(permissions.publish, patterns(&["alice/**"]));
594 }
595
596 #[test]
597 fn test_authorize_path_extends_root() {
598 let claims = authorize_claims("room/123", &["bob/**"], &["alice/**"]);
600 let permissions = claims.authorize("room/123/alice").unwrap();
601
602 assert_eq!(permissions.subscribe, Patterns::new());
603 assert_eq!(permissions.publish, patterns(&["**"]));
604 }
605
606 #[test]
607 fn test_authorize_literal_becomes_the_path_itself() {
608 let claims = authorize_claims("room", &[], &["alice"]);
610 let permissions = claims.authorize("room/alice").unwrap();
611
612 assert_eq!(permissions.publish, patterns(&[""]));
613 }
614
615 #[test]
616 fn test_authorize_path_is_parent_of_root() {
617 let claims = authorize_claims("demo", &["**"], &["alice/**"]);
619 let permissions = claims.authorize("/").unwrap();
620
621 assert_eq!(permissions.subscribe, patterns(&["demo/**"]));
622 assert_eq!(permissions.publish, patterns(&["demo/alice/**"]));
623 }
624
625 #[test]
626 fn test_authorize_empty_root() {
627 let claims = authorize_claims("", &["demo/**"], &[]);
629 let permissions = claims.authorize("demo/room").unwrap();
630
631 assert_eq!(permissions.subscribe, patterns(&["**"]));
632 assert_eq!(permissions.publish, Patterns::new());
633 }
634
635 #[test]
636 fn test_authorize_slashes_are_implicit() {
637 let claims = authorize_claims("/room/123/", &["bob/**"], &[]);
638 let permissions = claims.authorize("//room/123//").unwrap();
639
640 assert_eq!(permissions.subscribe, patterns(&["bob/**"]));
641 }
642
643 #[test]
644 fn test_authorize_respects_segment_boundaries() {
645 let claims = authorize_claims("foo", &["**"], &["**"]);
647 assert!(matches!(claims.authorize("foobar"), Err(crate::Error::RootMismatch(_))));
648 }
649
650 #[test]
651 fn test_authorize_unrelated_path() {
652 let claims = authorize_claims("demo", &["**"], &["**"]);
653 assert!(matches!(claims.authorize("other"), Err(crate::Error::RootMismatch(_))));
654 }
655
656 #[test]
657 fn test_authorize_no_access_at_path() {
658 let claims = authorize_claims("", &["demo/**"], &[]);
660 assert!(matches!(claims.authorize("other"), Err(crate::Error::NoAccess(_))));
661 }
662
663 #[test]
664 fn test_authorize_wildcards_rebase_as_a_set() {
665 let claims = authorize_claims("", &["**/chat"], &[]);
667 let permissions = claims.authorize("chat").unwrap();
668 assert_eq!(permissions.subscribe.len(), 2);
669 assert_eq!(permissions.subscribe, patterns(&["", "**/chat"]));
670 }
671}