use axum::http::Method;
use cloudillo_types::types::TokenScope;
pub fn has_scope(scopes: &str, needed: &str) -> bool {
scopes.split(',').map(str::trim).any(|s| s == needed)
}
const CARDDAV_PREFIXES: &[&str] = &["/api/address-books", "/api/contacts"];
const CALDAV_PREFIXES: &[&str] = &["/api/calendars"];
fn path_in_family(path: &str, prefix: &str) -> bool {
path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
}
fn is_read_method(method: &Method) -> bool {
matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
}
fn capability_permits(scopes: &str, method: &Method, prefix: &str) -> bool {
let read = format!("{prefix}:read");
if !has_scope(scopes, &read) {
return false;
}
is_read_method(method) || has_scope(scopes, &format!("{prefix}:write"))
}
pub fn scope_permits(scope: Option<&str>, method: &Method, path: &str) -> bool {
let Some(scope) = scope else {
return true;
};
match TokenScope::parse(scope) {
Some(TokenScope::File { .. }) => {
path.starts_with("/api/files/")
|| path == "/api/files"
|| path == "/api/search"
|| path.starts_with("/ws/rtdb/")
|| path.starts_with("/ws/crdt/")
|| path == "/api/auth/access-token"
}
Some(TokenScope::ApkgPublish) => {
path.starts_with("/api/files/apkg/")
|| (path == "/api/actions" && method == Method::POST)
|| path.starts_with("/api/apps")
}
None => {
if CARDDAV_PREFIXES.iter().any(|p| path_in_family(path, p)) {
capability_permits(scope, method, "carddav")
} else if CALDAV_PREFIXES.iter().any(|p| path_in_family(path, p)) {
capability_permits(scope, method, "caldav")
} else {
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_scope_exact_match_only() {
assert!(has_scope("carddav:read", "carddav:read"));
assert!(has_scope("carddav:read,carddav:write", "carddav:read"));
assert!(has_scope("carddav:read, carddav:write", "carddav:write"));
assert!(has_scope("other,carddav:write", "carddav:write"));
assert!(!has_scope("carddav:reader", "carddav:read"));
assert!(!has_scope("", "carddav:read"));
assert!(!has_scope("carddav", "carddav:read"));
assert!(!has_scope("carddav:read carddav:write", "carddav:read"));
}
#[test]
fn unscoped_is_unrestricted() {
for path in ["/api/files/x", "/api/idp/identities", "/api/settings/foo", "/api/anything"] {
assert!(scope_permits(None, &Method::GET, path));
assert!(scope_permits(None, &Method::POST, path));
}
}
#[test]
fn blank_scope_string_grants_nothing() {
for path in ["/api/files/x", "/api/address-books", "/api/idp/identities", "/api/anything"] {
assert!(!scope_permits(Some(""), &Method::GET, path));
assert!(!scope_permits(Some(" "), &Method::POST, path));
}
}
#[test]
fn file_scope_stays_on_the_file_surface() {
let s = Some("file:f1~abc:W");
assert!(scope_permits(s, &Method::GET, "/api/files/x"));
assert!(scope_permits(s, &Method::GET, "/api/files"));
assert!(scope_permits(s, &Method::GET, "/ws/crdt/f1~abc"));
assert!(scope_permits(s, &Method::POST, "/api/auth/access-token"));
assert!(scope_permits(s, &Method::GET, "/api/search"));
assert!(!scope_permits(s, &Method::POST, "/api/search/reindex"));
assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
assert!(!scope_permits(s, &Method::PUT, "/api/settings/foo"));
assert!(!scope_permits(s, &Method::GET, "/api/auth/proxy-token"));
assert!(!scope_permits(s, &Method::GET, "/api/doc-formats"));
assert!(!scope_permits(s, &Method::PUT, "/api/doc-formats/cloudillo%2Fnotillo"));
}
#[test]
fn apkg_scope_stays_on_the_publish_surface() {
let s = Some("apkg:publish");
assert!(scope_permits(s, &Method::POST, "/api/files/apkg/upload"));
assert!(scope_permits(s, &Method::POST, "/api/actions"));
assert!(!scope_permits(s, &Method::GET, "/api/actions"));
assert!(scope_permits(s, &Method::GET, "/api/apps/installed"));
assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
}
#[test]
fn carddav_read_is_read_only_and_carddav_only() {
let s = Some("carddav:read");
assert!(scope_permits(s, &Method::GET, "/api/address-books"));
assert!(scope_permits(s, &Method::GET, "/api/contacts"));
assert!(!scope_permits(s, &Method::POST, "/api/address-books"));
assert!(!scope_permits(s, &Method::GET, "/api/calendars"));
assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
assert!(!scope_permits(s, &Method::PUT, "/api/settings/idp.enabled"));
assert!(!scope_permits(s, &Method::GET, "/api/auth/api-keys"));
}
#[test]
fn carddav_write_permits_mutations() {
let s = Some("carddav:read,carddav:write");
assert!(scope_permits(s, &Method::GET, "/api/address-books"));
assert!(scope_permits(s, &Method::POST, "/api/address-books"));
assert!(scope_permits(s, &Method::PUT, "/api/address-books/ab1/contacts/u1"));
assert!(!scope_permits(s, &Method::POST, "/api/calendars"));
assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
}
#[test]
fn caldav_scope_stays_on_the_calendar_surface() {
let s = Some("caldav:read");
assert!(scope_permits(s, &Method::GET, "/api/calendars/x/objects"));
assert!(!scope_permits(s, &Method::POST, "/api/calendars/x/objects"));
assert!(!scope_permits(s, &Method::GET, "/api/address-books"));
}
#[test]
fn prefix_matching_is_segment_aware() {
let s = Some("carddav:read");
assert!(scope_permits(s, &Method::GET, "/api/contacts"));
assert!(scope_permits(s, &Method::GET, "/api/contacts/x"));
assert!(!scope_permits(s, &Method::GET, "/api/contacts-export"));
assert!(!scope_permits(s, &Method::GET, "/api/address-books-admin"));
}
#[test]
fn unrecognised_scope_grants_nothing() {
for s in ["admin", "nonsense", "carddav", "read"] {
for path in [
"/api/address-books",
"/api/calendars",
"/api/contacts",
"/api/files/x",
"/api/idp/identities",
"/api/settings/foo",
"/",
] {
assert!(!scope_permits(Some(s), &Method::GET, path), "{s} on {path}");
assert!(!scope_permits(Some(s), &Method::POST, path), "{s} on {path}");
}
}
}
}