use crate::path;
use serde::{Deserialize, Serialize};
use serde_with::{OneOrMany, TimestampSeconds, formats::PreferMany, serde_as};
#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
pub struct Scope {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub root: String,
#[serde(default, rename = "put", skip_serializing_if = "Vec::is_empty")]
pub publish: Vec<String>,
#[serde(default, rename = "get", skip_serializing_if = "Vec::is_empty")]
pub subscribe: Vec<String>,
}
impl Scope {
pub fn validate(&self) -> crate::Result<()> {
if self.publish.is_empty() && self.subscribe.is_empty() {
return Err(crate::Error::UselessScope);
}
Ok(())
}
pub fn allows(&self, claims: &Claims) -> bool {
covers(&self.root, &self.publish, &claims.root, &claims.publish)
&& covers(&self.root, &self.subscribe, &claims.root, &claims.subscribe)
}
}
fn covers(scope_root: &str, granted: &[String], claims_root: &str, requested: &[String]) -> bool {
let absolute = |root: &str, relative: &str| path::join(&path::normalize(root), &path::normalize(relative));
requested.iter().all(|request| {
let request = absolute(claims_root, request);
granted
.iter()
.any(|grant| path::has_prefix(&request, &absolute(scope_root, grant)))
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Permissions {
pub subscribe: Vec<String>,
pub publish: Vec<String>,
}
#[serde_with::skip_serializing_none]
#[serde_as]
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(default)]
#[non_exhaustive]
pub struct Claims {
#[serde(default, rename = "root", skip_serializing_if = "String::is_empty")]
pub root: String,
#[serde(default, rename = "put", skip_serializing_if = "Vec::is_empty")]
#[serde_as(as = "OneOrMany<_, PreferMany>")]
pub publish: Vec<String>,
#[serde(default, rename = "get", skip_serializing_if = "Vec::is_empty")]
#[serde_as(as = "OneOrMany<_, PreferMany>")]
pub subscribe: Vec<String>,
#[serde(rename = "exp")]
#[serde_as(as = "Option<TimestampSeconds<i64>>")]
pub expires: Option<std::time::SystemTime>,
#[serde(rename = "iat")]
#[serde_as(as = "Option<TimestampSeconds<i64>>")]
pub issued: Option<std::time::SystemTime>,
}
impl Claims {
pub fn with_root(mut self, root: impl Into<String>) -> Self {
self.root = root.into();
self
}
pub fn with_publish(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.publish = paths.into_iter().map(Into::into).collect();
self
}
pub fn with_subscribe(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.subscribe = paths.into_iter().map(Into::into).collect();
self
}
pub fn with_expires(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
self.expires = at.into();
self
}
pub fn with_issued(mut self, at: impl Into<Option<std::time::SystemTime>>) -> Self {
self.issued = at.into();
self
}
pub fn validate(&self) -> crate::Result<()> {
if self.publish.is_empty() && self.subscribe.is_empty() {
return Err(crate::Error::UselessToken);
}
Ok(())
}
pub fn authorize(&self, path: &str) -> crate::Result<Permissions> {
let path = path::normalize(path);
let root = path::normalize(&self.root);
let (suffix, prefix) = if let Some(suffix) = path::strip_prefix(&path, &root) {
(suffix, "")
} else if let Some(prefix) = path::strip_prefix(&root, &path) {
("", prefix)
} else {
return Err(crate::Error::RootMismatch(path));
};
let scope = |paths: &[String]| -> Vec<String> {
paths
.iter()
.filter_map(|granted| {
let granted = path::join(prefix, &path::normalize(granted));
if let Some(remaining) = path::strip_prefix(&granted, suffix) {
Some(remaining.to_string())
} else if path::has_prefix(suffix, &granted) {
Some(String::new())
} else {
None
}
})
.collect()
};
let permissions = Permissions {
subscribe: scope(&self.subscribe),
publish: scope(&self.publish),
};
if permissions.subscribe.is_empty() && permissions.publish.is_empty() {
return Err(crate::Error::NoAccess(path));
}
Ok(permissions)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, SystemTime};
fn create_test_claims() -> Claims {
Claims {
root: "test-path".to_string(),
publish: vec!["test-pub".into()],
subscribe: vec!["test-sub".into()],
expires: Some(SystemTime::now() + Duration::from_secs(3600)),
issued: Some(SystemTime::now()),
}
}
#[test]
fn scope_allows_contained_claims() {
let scope = Scope {
root: "project".into(),
publish: vec!["live".into()],
subscribe: vec!["watch".into()],
};
let claims = Claims {
root: "project/live/room".into(),
publish: vec!["".into()],
subscribe: vec![],
..Default::default()
};
assert!(scope.allows(&claims));
}
#[test]
fn scope_rejects_sibling_and_role_escalation() {
let scope = Scope {
root: "project".into(),
publish: vec!["live".into()],
subscribe: vec![],
};
let sibling = Claims {
root: "project/lively".into(),
publish: vec!["".into()],
..Default::default()
};
let role = Claims {
root: "project/live".into(),
subscribe: vec!["".into()],
..Default::default()
};
assert!(!scope.allows(&sibling));
assert!(!scope.allows(&role));
}
#[test]
fn scope_ignores_how_the_root_is_split() {
let scope = Scope {
root: "project".into(),
publish: vec!["live".into()],
subscribe: vec![],
};
for claims in [
Claims {
root: "project".into(),
publish: vec!["live/room".into()],
..Default::default()
},
Claims {
root: String::new(),
publish: vec!["project/live/room".into()],
..Default::default()
},
Claims {
root: "/project/live/".into(),
publish: vec!["/room".into()],
..Default::default()
},
] {
assert!(scope.allows(&claims), "{claims:?}");
}
}
#[test]
fn scope_rejects_escaping_above_its_root() {
let scope = Scope {
root: "project".into(),
publish: vec!["live".into()],
subscribe: vec![],
};
let claims = Claims {
root: String::new(),
publish: vec!["".into()],
..Default::default()
};
assert!(!scope.allows(&claims));
}
#[test]
fn scope_empty_prefix_grants_everything_beneath_it() {
let scope = Scope {
root: "project".into(),
publish: vec![String::new()],
subscribe: vec![],
};
let claims = Claims {
root: "project/anything/deep".into(),
publish: vec!["".into()],
..Default::default()
};
assert!(scope.allows(&claims));
}
#[test]
fn scope_requires_every_requested_path() {
let scope = Scope {
root: "project".into(),
publish: vec!["live".into()],
subscribe: vec![],
};
let claims = Claims {
root: "project".into(),
publish: vec!["live/room".into(), "other".into()],
..Default::default()
};
assert!(!scope.allows(&claims));
}
#[test]
fn scope_without_grants_is_useless() {
assert!(matches!(Scope::default().validate(), Err(crate::Error::UselessScope)));
}
#[test]
fn test_claims_validation_success() {
let claims = create_test_claims();
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_no_publish_or_subscribe() {
let claims = Claims {
root: "test-path".to_string(),
publish: vec![],
subscribe: vec![],
expires: None,
issued: None,
};
let result = claims.validate();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no publish or subscribe allowed; token is useless")
);
}
#[test]
fn test_claims_validation_only_publish() {
let claims = Claims {
root: "test-path".to_string(),
publish: vec!["test-pub".into()],
subscribe: vec![],
expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_only_subscribe() {
let claims = Claims {
root: "test-path".to_string(),
publish: vec![],
subscribe: vec!["test-sub".into()],
expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_path_not_prefix_relative_publish() {
let claims = Claims {
root: "test-path".to_string(), publish: vec!["relative-pub".into()], subscribe: vec![],
expires: None,
issued: None,
};
let result = claims.validate();
assert!(result.is_ok()); }
#[test]
fn test_claims_validation_path_not_prefix_relative_subscribe() {
let claims = Claims {
root: "test-path".to_string(), publish: vec![],
subscribe: vec!["relative-sub".into()], expires: None,
issued: None,
};
let result = claims.validate();
assert!(result.is_ok()); }
#[test]
fn test_claims_validation_path_not_prefix_absolute_publish() {
let claims = Claims {
root: "test-path".to_string(), publish: vec!["/absolute-pub".into()], subscribe: vec![],
expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_path_not_prefix_absolute_subscribe() {
let claims = Claims {
root: "test-path".to_string(), publish: vec![],
subscribe: vec!["/absolute-sub".into()], expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_path_not_prefix_empty_publish() {
let claims = Claims {
root: "test-path".to_string(), publish: vec!["".into()], subscribe: vec![],
expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_path_not_prefix_empty_subscribe() {
let claims = Claims {
root: "test-path".to_string(), publish: vec![],
subscribe: vec!["".into()], expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_path_is_prefix() {
let claims = Claims {
root: "test-path".to_string(), publish: vec!["relative-pub".into()], subscribe: vec!["relative-sub".into()], expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_validation_empty_path() {
let claims = Claims {
root: "".to_string(), publish: vec!["test-pub".into()],
subscribe: vec![],
expires: None,
issued: None,
};
assert!(claims.validate().is_ok());
}
#[test]
fn test_claims_serde() {
let claims = create_test_claims();
let json = serde_json::to_string(&claims).unwrap();
let deserialized: Claims = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.root, claims.root);
assert_eq!(deserialized.publish, claims.publish);
assert_eq!(deserialized.subscribe, claims.subscribe);
}
#[test]
fn test_claims_default() {
let claims = Claims::default();
assert_eq!(claims.root, "");
assert!(claims.publish.is_empty());
assert!(claims.subscribe.is_empty());
assert_eq!(claims.expires, None);
assert_eq!(claims.issued, None);
}
fn authorize_claims(root: &str, subscribe: &[&str], publish: &[&str]) -> Claims {
Claims {
root: root.to_string(),
subscribe: subscribe.iter().map(|s| s.to_string()).collect(),
publish: publish.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn test_authorize_path_equals_root() {
let claims = authorize_claims("room/123", &[""], &["alice"]);
let permissions = claims.authorize("room/123").unwrap();
assert_eq!(permissions.subscribe, [""]);
assert_eq!(permissions.publish, ["alice"]);
}
#[test]
fn test_authorize_path_extends_root() {
let claims = authorize_claims("room/123", &["bob"], &["alice"]);
let permissions = claims.authorize("room/123/alice").unwrap();
assert_eq!(permissions.subscribe, Vec::<String>::new());
assert_eq!(permissions.publish, [""]);
}
#[test]
fn test_authorize_path_is_parent_of_root() {
let claims = authorize_claims("demo", &[""], &["alice"]);
let permissions = claims.authorize("/").unwrap();
assert_eq!(permissions.subscribe, ["demo"]);
assert_eq!(permissions.publish, ["demo/alice"]);
}
#[test]
fn test_authorize_empty_root() {
let claims = authorize_claims("", &["demo"], &[]);
let permissions = claims.authorize("demo/room").unwrap();
assert_eq!(permissions.subscribe, [""]);
assert_eq!(permissions.publish, Vec::<String>::new());
}
#[test]
fn test_authorize_slashes_are_implicit() {
let claims = authorize_claims("/room/123/", &["/bob/"], &[]);
let permissions = claims.authorize("//room/123//").unwrap();
assert_eq!(permissions.subscribe, ["bob"]);
}
#[test]
fn test_authorize_respects_segment_boundaries() {
let claims = authorize_claims("foo", &[""], &[""]);
assert!(matches!(claims.authorize("foobar"), Err(crate::Error::RootMismatch(_))));
}
#[test]
fn test_authorize_unrelated_path() {
let claims = authorize_claims("demo", &[""], &[""]);
assert!(matches!(claims.authorize("other"), Err(crate::Error::RootMismatch(_))));
}
#[test]
fn test_authorize_no_access_at_path() {
let claims = authorize_claims("", &["demo"], &[]);
assert!(matches!(claims.authorize("other"), Err(crate::Error::NoAccess(_))));
}
#[test]
fn test_deserialize_string_as_vec() {
let json = r#"{
"root": "test",
"put": "single-publish",
"get": "single-subscribe"
}"#;
let claims: Claims = serde_json::from_str(json).unwrap();
assert_eq!(claims.publish, vec!["single-publish"]);
assert_eq!(claims.subscribe, vec!["single-subscribe"]);
}
#[test]
fn test_deserialize_vec_as_vec() {
let json = r#"{
"root": "test",
"put": ["pub1", "pub2"],
"get": ["sub1", "sub2"]
}"#;
let claims: Claims = serde_json::from_str(json).unwrap();
assert_eq!(claims.publish, vec!["pub1", "pub2"]);
assert_eq!(claims.subscribe, vec!["sub1", "sub2"]);
}
#[test]
fn test_deserialize_mixed() {
let json = r#"{
"root": "test",
"put": "single",
"get": ["multi1", "multi2"]
}"#;
let claims: Claims = serde_json::from_str(json).unwrap();
assert_eq!(claims.publish, vec!["single"]);
assert_eq!(claims.subscribe, vec!["multi1", "multi2"]);
}
}