1use axum::http::Method;
18use cloudillo_types::types::TokenScope;
19
20pub fn has_scope(scopes: &str, needed: &str) -> bool {
23 scopes.split(',').map(str::trim).any(|s| s == needed)
24}
25
26const CARDDAV_PREFIXES: &[&str] = &["/api/address-books", "/api/contacts"];
29const CALDAV_PREFIXES: &[&str] = &["/api/calendars"];
30
31fn path_in_family(path: &str, prefix: &str) -> bool {
35 path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
36}
37
38fn is_read_method(method: &Method) -> bool {
39 matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS)
40}
41
42fn capability_permits(scopes: &str, method: &Method, prefix: &str) -> bool {
45 let read = format!("{prefix}:read");
46 if !has_scope(scopes, &read) {
47 return false;
48 }
49 is_read_method(method) || has_scope(scopes, &format!("{prefix}:write"))
50}
51
52pub fn scope_permits(scope: Option<&str>, method: &Method, path: &str) -> bool {
62 let Some(scope) = scope else {
63 return true;
64 };
65
66 match TokenScope::parse(scope) {
67 Some(TokenScope::File { .. }) => {
68 path.starts_with("/api/files/")
69 || path == "/api/files"
70 || path == "/api/search"
76 || path.starts_with("/ws/rtdb/")
77 || path.starts_with("/ws/crdt/")
78 || path == "/api/auth/access-token"
81 }
82 Some(TokenScope::ApkgPublish) => {
85 path.starts_with("/api/files/apkg/")
86 || (path == "/api/actions" && method == Method::POST)
87 || path.starts_with("/api/apps")
88 }
89 None => {
91 if CARDDAV_PREFIXES.iter().any(|p| path_in_family(path, p)) {
92 capability_permits(scope, method, "carddav")
93 } else if CALDAV_PREFIXES.iter().any(|p| path_in_family(path, p)) {
94 capability_permits(scope, method, "caldav")
95 } else {
96 false
97 }
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn has_scope_exact_match_only() {
108 assert!(has_scope("carddav:read", "carddav:read"));
109 assert!(has_scope("carddav:read,carddav:write", "carddav:read"));
110 assert!(has_scope("carddav:read, carddav:write", "carddav:write"));
111 assert!(has_scope("other,carddav:write", "carddav:write"));
112 assert!(!has_scope("carddav:reader", "carddav:read"));
113 assert!(!has_scope("", "carddav:read"));
114 assert!(!has_scope("carddav", "carddav:read"));
115 assert!(!has_scope("carddav:read carddav:write", "carddav:read"));
117 }
118
119 #[test]
120 fn unscoped_is_unrestricted() {
121 for path in ["/api/files/x", "/api/idp/identities", "/api/settings/foo", "/api/anything"] {
122 assert!(scope_permits(None, &Method::GET, path));
123 assert!(scope_permits(None, &Method::POST, path));
124 }
125 }
126
127 #[test]
128 fn blank_scope_string_grants_nothing() {
129 for path in ["/api/files/x", "/api/address-books", "/api/idp/identities", "/api/anything"] {
132 assert!(!scope_permits(Some(""), &Method::GET, path));
133 assert!(!scope_permits(Some(" "), &Method::POST, path));
134 }
135 }
136
137 #[test]
138 fn file_scope_stays_on_the_file_surface() {
139 let s = Some("file:f1~abc:W");
140 assert!(scope_permits(s, &Method::GET, "/api/files/x"));
141 assert!(scope_permits(s, &Method::GET, "/api/files"));
142 assert!(scope_permits(s, &Method::GET, "/ws/crdt/f1~abc"));
143 assert!(scope_permits(s, &Method::POST, "/api/auth/access-token"));
145 assert!(scope_permits(s, &Method::GET, "/api/search"));
147 assert!(!scope_permits(s, &Method::POST, "/api/search/reindex"));
150
151 assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
152 assert!(!scope_permits(s, &Method::PUT, "/api/settings/foo"));
153 assert!(!scope_permits(s, &Method::GET, "/api/auth/proxy-token"));
154 assert!(!scope_permits(s, &Method::GET, "/api/doc-formats"));
156 assert!(!scope_permits(s, &Method::PUT, "/api/doc-formats/cloudillo%2Fnotillo"));
157 }
158
159 #[test]
160 fn apkg_scope_stays_on_the_publish_surface() {
161 let s = Some("apkg:publish");
162 assert!(scope_permits(s, &Method::POST, "/api/files/apkg/upload"));
163 assert!(scope_permits(s, &Method::POST, "/api/actions"));
164 assert!(!scope_permits(s, &Method::GET, "/api/actions"));
165 assert!(scope_permits(s, &Method::GET, "/api/apps/installed"));
166 assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
167 }
168
169 #[test]
170 fn carddav_read_is_read_only_and_carddav_only() {
171 let s = Some("carddav:read");
172 assert!(scope_permits(s, &Method::GET, "/api/address-books"));
173 assert!(scope_permits(s, &Method::GET, "/api/contacts"));
174 assert!(!scope_permits(s, &Method::POST, "/api/address-books"));
175 assert!(!scope_permits(s, &Method::GET, "/api/calendars"));
176
177 assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
179 assert!(!scope_permits(s, &Method::PUT, "/api/settings/idp.enabled"));
180 assert!(!scope_permits(s, &Method::GET, "/api/auth/api-keys"));
181 }
182
183 #[test]
184 fn carddav_write_permits_mutations() {
185 let s = Some("carddav:read,carddav:write");
187 assert!(scope_permits(s, &Method::GET, "/api/address-books"));
188 assert!(scope_permits(s, &Method::POST, "/api/address-books"));
189 assert!(scope_permits(s, &Method::PUT, "/api/address-books/ab1/contacts/u1"));
190 assert!(!scope_permits(s, &Method::POST, "/api/calendars"));
191 assert!(!scope_permits(s, &Method::POST, "/api/idp/identities"));
192 }
193
194 #[test]
195 fn caldav_scope_stays_on_the_calendar_surface() {
196 let s = Some("caldav:read");
197 assert!(scope_permits(s, &Method::GET, "/api/calendars/x/objects"));
198 assert!(!scope_permits(s, &Method::POST, "/api/calendars/x/objects"));
199 assert!(!scope_permits(s, &Method::GET, "/api/address-books"));
200 }
201
202 #[test]
203 fn prefix_matching_is_segment_aware() {
204 let s = Some("carddav:read");
205 assert!(scope_permits(s, &Method::GET, "/api/contacts"));
207 assert!(scope_permits(s, &Method::GET, "/api/contacts/x"));
208 assert!(!scope_permits(s, &Method::GET, "/api/contacts-export"));
210 assert!(!scope_permits(s, &Method::GET, "/api/address-books-admin"));
211 }
212
213 #[test]
214 fn unrecognised_scope_grants_nothing() {
215 for s in ["admin", "nonsense", "carddav", "read"] {
216 for path in [
217 "/api/address-books",
218 "/api/calendars",
219 "/api/contacts",
220 "/api/files/x",
221 "/api/idp/identities",
222 "/api/settings/foo",
223 "/",
224 ] {
225 assert!(!scope_permits(Some(s), &Method::GET, path), "{s} on {path}");
226 assert!(!scope_permits(Some(s), &Method::POST, path), "{s} on {path}");
227 }
228 }
229 }
230}
231
232