Skip to main content

cloudillo_core/
scope.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Central, fail-closed scope enforcement for scoped credentials.
5//!
6//! Two unrelated credential families carry a `scope` string:
7//!
8//! - **Delegated tokens** — share links (`file:{file_id}:{R|C|W}`) and app
9//!   publishing (`apkg:publish`), parsed by
10//!   [`cloudillo_types::types::TokenScope`].
11//! - **Capability scopes** — the comma-separated `carddav:*` / `caldav:*` list a
12//!   user types into the `scopes` field of `POST /api/auth/api-keys`.
13//!
14//! [`scope_permits`] is the single decision point for both, called from
15//! `crate::middleware::require_auth` on every protected request.
16
17use axum::http::Method;
18use cloudillo_types::types::TokenScope;
19
20/// Returns `true` iff `scopes` (comma-separated) contains an exact-match token for `needed`.
21/// Whitespace around each token is trimmed.
22pub fn has_scope(scopes: &str, needed: &str) -> bool {
23	scopes.split(',').map(str::trim).any(|s| s == needed)
24}
25
26/// REST equivalents of the `/dav/*` surface that `cloudillo_dav::auth::dav_basic_auth`
27/// guards, so one `carddav:*` / `caldav:*` key means the same thing on both.
28const CARDDAV_PREFIXES: &[&str] = &["/api/address-books", "/api/contacts"];
29const CALDAV_PREFIXES: &[&str] = &["/api/calendars"];
30
31/// Whether `path` is the `prefix` collection itself or a resource inside it.
32/// Segment-aware: `/api/contacts` matches `/api/contacts` and `/api/contacts/x`,
33/// but not `/api/contacts-export`.
34fn 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
42/// Whether the capability list `scopes` covers `method` for a `prefix`-family
43/// path. Write methods imply read, mirroring `cloudillo_dav::auth::required_scopes`.
44fn 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
52/// Whether a credential carrying `scope` may perform `method` on `path`.
53///
54/// **Fails closed**: an unrecognised scope string grants nothing, anywhere. Tenant
55/// API keys are minted with the full tenant-owner role set regardless of their
56/// `scopes` column, so "unrecognised" must never degrade to "unrestricted".
57///
58/// `None` means an unscoped credential — unrestricted here, gated by roles/ABAC
59/// instead. `validate_api_key` normalises a blank `scopes` column to `None`; a blank
60/// string arriving anyway is a capability list with no capabilities, and grants nothing.
61pub 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				// Document-scoped full-text search: an app (or share-link guest)
71				// searching inside the one document it was handed; the handler
72				// confines results to that document's tree and to file/document
73				// rows. `/api/doc-formats` is deliberately NOT here — registering
74				// index rules is shell-mediated, out of reach of app credentials.
75				|| path == "/api/search"
76				|| path.starts_with("/ws/rtdb/")
77				|| path.starts_with("/ws/crdt/")
78				// Reachable for the `?via=` cross-document re-scoping branch, which takes
79				// a scoped bearer; the bare branch rejects scoped tokens in the handler.
80				|| path == "/api/auth/access-token"
81		}
82		// Deliberately narrow: only what app publishing needs, to limit the blast
83		// radius of a compromised token.
84		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		// Not a delegated token — treat it as a capability list.
90		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		// Space-separation is NOT accepted — use commas.
116		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		// `validate_api_key` normalises blank to `None` upstream, so a blank string
130		// arriving here is a capability list with no capabilities, not "unrestricted".
131		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		// Must stay reachable for `?via=`; rejecting the bare branch is the handler's job.
144		assert!(scope_permits(s, &Method::POST, "/api/auth/access-token"));
145		// In-document search: the handler confines results to the scoped tree.
146		assert!(scope_permits(s, &Method::GET, "/api/search"));
147		// ...but the whitelist is an exact match, so the sibling rebuild route
148		// (owner/leader only) is not delegable to a file-scoped token.
149		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		// Claiming a document type is never delegable to an app's own token.
155		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		// A DAV-scoped key must not reach tenant management APIs.
178		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		// A capability-scoped key must still reach its own surface.
186		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		// The collection itself and resources inside it.
206		assert!(scope_permits(s, &Method::GET, "/api/contacts"));
207		assert!(scope_permits(s, &Method::GET, "/api/contacts/x"));
208		// A sibling route that merely shares a textual prefix is not in the family.
209		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// vim: ts=4