objectstore_types/scope.rs
1//! Hierarchical namespace for object organization and authorization.
2//!
3//! This module defines [`Scope`] (a single key-value pair like
4//! `organization=17`) and [`Scopes`] (an ordered collection of scopes).
5//!
6//! # Allowed characters
7//!
8//! Scope keys and values must be non-empty and may only contain:
9//!
10//! ```text
11//! A-Z a-z 0-9 _ - ( ) $ ! + '
12//! ```
13//!
14//! Characters used as delimiters are forbidden: `.` (storage path separator),
15//! `/` (path separator), `=` and `;` (API path encoding).
16//!
17//! # Ordering
18//!
19//! Order matters — `organization=17;project=42` and `project=42;organization=17`
20//! identify different object namespaces because they produce different storage
21//! paths.
22//!
23//! # Purpose
24//!
25//! Scopes serve several roles:
26//!
27//! 1. **Organization** — they define a hierarchical folder-like structure
28//! within a usecase. The storage path directly reflects the scope hierarchy
29//! (e.g. `org.17/project.42/objects/{key}`).
30//! 2. **Authorization** — JWT tokens include scope claims that are matched
31//! against the request's scopes. A token scoped to `organization=17` can
32//! only access objects under that organization.
33//! 3. **Compartmentalization** — scopes isolate impact through rate limits and
34//! killswitches, guaranteeing quality of service between tenants.
35//!
36//! # Display formats
37//!
38//! Scopes have two display formats:
39//!
40//! - **Storage path** ([`Scopes::as_storage_path`]): `org.17/project.42` —
41//! used by backends to construct storage keys.
42//! - **API path** ([`Scopes::as_api_path`]): `org=17;project=42` — used in
43//! HTTP URL paths (matrix URI syntax). Empty scopes render as `_`.
44
45use std::fmt;
46
47/// Characters allowed in a Scope's key and value.
48///
49/// These are the URL safe characters, except for `.` which we use as separator between
50/// key and value of Scope components in backends.
51const ALLOWED_CHARS: &str =
52 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-()$!+'";
53
54/// Used in place of scopes in the URL to represent an empty set of scopes.
55pub const EMPTY_SCOPES: &str = "_";
56
57/// A single scope value of an object.
58///
59/// See the [module-level documentation](self) for allowed characters, ordering,
60/// and the roles scopes play.
61#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)]
62pub struct Scope {
63 /// Identifies the scope.
64 ///
65 /// Examples are `organization` or `project`.
66 pub name: String,
67 /// The value of the scope.
68 ///
69 /// This can be the identifier of a database entity or a unique value.
70 pub value: String,
71}
72
73impl Scope {
74 /// Creates and validates a new scope.
75 ///
76 /// The name and value must be non-empty.
77 ///
78 /// # Examples
79 ///
80 /// ```
81 /// use objectstore_types::scope::Scope;
82 ///
83 /// let scope = Scope::create("organization", "17").unwrap();
84 /// assert_eq!(scope.name(), "organization");
85 /// assert_eq!(scope.value(), "17");
86 ///
87 /// // Empty names or values are invalid
88 /// let invalid_scope = Scope::create("", "value");
89 /// assert!(invalid_scope.is_err());
90 /// ```
91 pub fn create<V>(name: &str, value: V) -> Result<Self, InvalidScopeError>
92 where
93 V: fmt::Display,
94 {
95 let value = value.to_string();
96 if name.is_empty() || value.is_empty() {
97 return Err(InvalidScopeError::Empty);
98 }
99
100 for c in name.chars().chain(value.chars()) {
101 if !ALLOWED_CHARS.contains(c) {
102 return Err(InvalidScopeError::InvalidChar(c));
103 }
104 }
105
106 Ok(Self {
107 name: name.to_owned(),
108 value,
109 })
110 }
111
112 /// Returns the name of the scope.
113 pub fn name(&self) -> &str {
114 &self.name
115 }
116
117 /// Returns the value of the scope.
118 pub fn value(&self) -> &str {
119 &self.value
120 }
121}
122
123/// An error indicating that a scope is invalid, returned by [`Scope::create`].
124#[derive(Debug, thiserror::Error)]
125pub enum InvalidScopeError {
126 /// Indicates that either the key or value is empty.
127 #[error("key and value must be non-empty")]
128 Empty,
129 /// Indicates that the key or value contains an invalid character.
130 #[error("invalid character '{0}'")]
131 InvalidChar(char),
132}
133
134/// An ordered set of resource scopes.
135///
136/// See the [module-level documentation](self) for allowed characters, ordering,
137/// and the roles scopes play.
138#[derive(Clone, Debug, PartialEq, Eq, Hash)]
139pub struct Scopes {
140 scopes: Vec<Scope>,
141}
142
143impl Scopes {
144 /// Returns an empty set of scopes.
145 pub fn empty() -> Self {
146 Self { scopes: vec![] }
147 }
148
149 /// Returns `true` if there are no scopes.
150 pub fn is_empty(&self) -> bool {
151 self.scopes.is_empty()
152 }
153
154 /// Returns the scope with the given key, if it exists.
155 pub fn get(&self, key: &str) -> Option<&Scope> {
156 self.scopes.iter().find(|s| s.name() == key)
157 }
158
159 /// Returns the value of the scope with the given key, if it exists.
160 pub fn get_value(&self, key: &str) -> Option<&str> {
161 self.get(key).map(|s| s.value())
162 }
163
164 /// Returns an iterator over all scopes.
165 pub fn iter(&self) -> impl Iterator<Item = &Scope> {
166 self.into_iter()
167 }
168
169 /// Pushes a new scope to the collection.
170 pub fn push<V>(&mut self, key: &str, value: V) -> Result<(), InvalidScopeError>
171 where
172 V: fmt::Display,
173 {
174 self.scopes.push(Scope::create(key, value)?);
175 Ok(())
176 }
177
178 /// Returns a view that formats the scopes as path for storage.
179 ///
180 /// This will serialize the scopes as `{name}.{value}/...`, which is intended to be used by
181 /// backends to reference the object in a storage system. This becomes part of the storage path
182 /// of an `ObjectId`.
183 pub fn as_storage_path(&self) -> AsStoragePath<'_> {
184 AsStoragePath { inner: self }
185 }
186
187 /// Returns a view that formats the scopes as path for web API usage.
188 ///
189 /// This will serialize the scopes as `{name}={value};...`, which is intended to be used by
190 /// clients to format URL paths.
191 pub fn as_api_path(&self) -> AsApiPath<'_> {
192 AsApiPath { inner: self }
193 }
194}
195
196impl<'a> IntoIterator for &'a Scopes {
197 type IntoIter = std::slice::Iter<'a, Scope>;
198 type Item = &'a Scope;
199
200 fn into_iter(self) -> Self::IntoIter {
201 self.scopes.iter()
202 }
203}
204
205impl FromIterator<Scope> for Scopes {
206 fn from_iter<T>(iter: T) -> Self
207 where
208 T: IntoIterator<Item = Scope>,
209 {
210 Self {
211 scopes: iter.into_iter().collect(),
212 }
213 }
214}
215
216/// A view returned by [`Scopes::as_storage_path`].
217#[derive(Debug)]
218pub struct AsStoragePath<'a> {
219 inner: &'a Scopes,
220}
221
222impl fmt::Display for AsStoragePath<'_> {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 for (i, scope) in self.inner.iter().enumerate() {
225 if i > 0 {
226 write!(f, "/")?;
227 }
228 write!(f, "{}.{}", scope.name, scope.value)?;
229 }
230 Ok(())
231 }
232}
233
234/// A view returned by [`Scopes::as_api_path`].
235#[derive(Debug)]
236pub struct AsApiPath<'a> {
237 inner: &'a Scopes,
238}
239
240impl fmt::Display for AsApiPath<'_> {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 if let Some((first, rest)) = self.inner.scopes.split_first() {
243 write!(f, "{}={}", first.name, first.value)?;
244 for scope in rest {
245 write!(f, ";{}={}", scope.name, scope.value)?;
246 }
247 Ok(())
248 } else {
249 f.write_str(EMPTY_SCOPES)
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 /// Regression test to ensure we're not unintentionally adding characters to the allowed set
259 /// that are required in storage or API paths.
260 #[test]
261 fn test_allowed_characters() {
262 // Storage paths
263 assert!(!ALLOWED_CHARS.contains('.'));
264 assert!(!ALLOWED_CHARS.contains('/'));
265
266 // API paths
267 assert!(!ALLOWED_CHARS.contains('='));
268 assert!(!ALLOWED_CHARS.contains(';'));
269 }
270
271 #[test]
272 fn test_create_scope_empty() {
273 let err = Scope::create("", "value").unwrap_err();
274 assert!(matches!(err, InvalidScopeError::Empty));
275
276 let err = Scope::create("key", "").unwrap_err();
277 assert!(matches!(err, InvalidScopeError::Empty));
278 }
279
280 #[test]
281 fn test_create_scope_invalid_char() {
282 let err = Scope::create("key/", "value").unwrap_err();
283 assert!(matches!(err, InvalidScopeError::InvalidChar('/')));
284
285 let err = dbg!(Scope::create("key", "⚠️").unwrap_err());
286 assert!(matches!(err, InvalidScopeError::InvalidChar('⚠')));
287 }
288
289 #[test]
290 fn test_as_storage_path() {
291 let scopes = Scopes::from_iter([
292 Scope::create("org", "12345").unwrap(),
293 Scope::create("project", "1337").unwrap(),
294 ]);
295
296 let storage_path = scopes.as_storage_path().to_string();
297 assert_eq!(storage_path, "org.12345/project.1337");
298
299 let empty_scopes = Scopes::empty();
300 let storage_path = empty_scopes.as_storage_path().to_string();
301 assert_eq!(storage_path, "");
302 }
303
304 #[test]
305 fn test_as_api_path() {
306 let scopes = Scopes::from_iter([
307 Scope::create("org", "12345").unwrap(),
308 Scope::create("project", "1337").unwrap(),
309 ]);
310
311 let api_path = scopes.as_api_path().to_string();
312 assert_eq!(api_path, "org=12345;project=1337");
313
314 let empty_scopes = Scopes::empty();
315 let api_path = empty_scopes.as_api_path().to_string();
316 assert_eq!(api_path, EMPTY_SCOPES);
317 }
318}