Skip to main content

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(Clone, 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    /// Placeholder error variant for use in `objectstore_client::auth::TokenGenerator`.
133    /// This should never be encountered in practice.
134    #[error("unexpected error, please report a bug")]
135    Unreachable,
136}
137
138/// An ordered set of resource scopes.
139///
140/// See the [module-level documentation](self) for allowed characters, ordering,
141/// and the roles scopes play.
142#[derive(Clone, Debug, PartialEq, Eq, Hash)]
143pub struct Scopes {
144    scopes: Vec<Scope>,
145}
146
147impl Scopes {
148    /// Returns an empty set of scopes.
149    pub fn empty() -> Self {
150        Self { scopes: vec![] }
151    }
152
153    /// Returns `true` if there are no scopes.
154    pub fn is_empty(&self) -> bool {
155        self.scopes.is_empty()
156    }
157
158    /// Returns the scope with the given key, if it exists.
159    pub fn get(&self, key: &str) -> Option<&Scope> {
160        self.scopes.iter().find(|s| s.name() == key)
161    }
162
163    /// Returns the value of the scope with the given key, if it exists.
164    pub fn get_value(&self, key: &str) -> Option<&str> {
165        self.get(key).map(|s| s.value())
166    }
167
168    /// Returns an iterator over all scopes.
169    pub fn iter(&self) -> impl Iterator<Item = &Scope> {
170        self.into_iter()
171    }
172
173    /// Pushes a new scope to the collection.
174    pub fn push<V>(&mut self, key: &str, value: V) -> Result<(), InvalidScopeError>
175    where
176        V: fmt::Display,
177    {
178        self.scopes.push(Scope::create(key, value)?);
179        Ok(())
180    }
181
182    /// Returns a view that formats the scopes as path for storage.
183    ///
184    /// This will serialize the scopes as `{name}.{value}/...`, which is intended to be used by
185    /// backends to reference the object in a storage system. This becomes part of the storage path
186    /// of an `ObjectId`.
187    pub fn as_storage_path(&self) -> AsStoragePath<'_> {
188        AsStoragePath { inner: self }
189    }
190
191    /// Returns a view that formats the scopes as path for web API usage.
192    ///
193    /// This will serialize the scopes as `{name}={value};...`, which is intended to be used by
194    /// clients to format URL paths.
195    pub fn as_api_path(&self) -> AsApiPath<'_> {
196        AsApiPath { inner: self }
197    }
198}
199
200impl<'a> IntoIterator for &'a Scopes {
201    type IntoIter = std::slice::Iter<'a, Scope>;
202    type Item = &'a Scope;
203
204    fn into_iter(self) -> Self::IntoIter {
205        self.scopes.iter()
206    }
207}
208
209impl FromIterator<Scope> for Scopes {
210    fn from_iter<T>(iter: T) -> Self
211    where
212        T: IntoIterator<Item = Scope>,
213    {
214        Self {
215            scopes: iter.into_iter().collect(),
216        }
217    }
218}
219
220/// A view returned by [`Scopes::as_storage_path`].
221#[derive(Debug)]
222pub struct AsStoragePath<'a> {
223    inner: &'a Scopes,
224}
225
226impl fmt::Display for AsStoragePath<'_> {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        for (i, scope) in self.inner.iter().enumerate() {
229            if i > 0 {
230                write!(f, "/")?;
231            }
232            write!(f, "{}.{}", scope.name, scope.value)?;
233        }
234        Ok(())
235    }
236}
237
238/// A view returned by [`Scopes::as_api_path`].
239#[derive(Debug)]
240pub struct AsApiPath<'a> {
241    inner: &'a Scopes,
242}
243
244impl fmt::Display for AsApiPath<'_> {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        if let Some((first, rest)) = self.inner.scopes.split_first() {
247            write!(f, "{}={}", first.name, first.value)?;
248            for scope in rest {
249                write!(f, ";{}={}", scope.name, scope.value)?;
250            }
251            Ok(())
252        } else {
253            f.write_str(EMPTY_SCOPES)
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    /// Regression test to ensure we're not unintentionally adding characters to the allowed set
263    /// that are required in storage or API paths.
264    #[test]
265    fn test_allowed_characters() {
266        // Storage paths
267        assert!(!ALLOWED_CHARS.contains('.'));
268        assert!(!ALLOWED_CHARS.contains('/'));
269
270        // API paths
271        assert!(!ALLOWED_CHARS.contains('='));
272        assert!(!ALLOWED_CHARS.contains(';'));
273    }
274
275    #[test]
276    fn test_create_scope_empty() {
277        let err = Scope::create("", "value").unwrap_err();
278        assert!(matches!(err, InvalidScopeError::Empty));
279
280        let err = Scope::create("key", "").unwrap_err();
281        assert!(matches!(err, InvalidScopeError::Empty));
282    }
283
284    #[test]
285    fn test_create_scope_invalid_char() {
286        let err = Scope::create("key/", "value").unwrap_err();
287        assert!(matches!(err, InvalidScopeError::InvalidChar('/')));
288
289        let err = dbg!(Scope::create("key", "⚠️").unwrap_err());
290        assert!(matches!(err, InvalidScopeError::InvalidChar('⚠')));
291    }
292
293    #[test]
294    fn test_as_storage_path() {
295        let scopes = Scopes::from_iter([
296            Scope::create("org", "12345").unwrap(),
297            Scope::create("project", "1337").unwrap(),
298        ]);
299
300        let storage_path = scopes.as_storage_path().to_string();
301        assert_eq!(storage_path, "org.12345/project.1337");
302
303        let empty_scopes = Scopes::empty();
304        let storage_path = empty_scopes.as_storage_path().to_string();
305        assert_eq!(storage_path, "");
306    }
307
308    #[test]
309    fn test_as_api_path() {
310        let scopes = Scopes::from_iter([
311            Scope::create("org", "12345").unwrap(),
312            Scope::create("project", "1337").unwrap(),
313        ]);
314
315        let api_path = scopes.as_api_path().to_string();
316        assert_eq!(api_path, "org=12345;project=1337");
317
318        let empty_scopes = Scopes::empty();
319        let api_path = empty_scopes.as_api_path().to_string();
320        assert_eq!(api_path, EMPTY_SCOPES);
321    }
322
323    #[test]
324    fn test_push_and_get() {
325        let mut scopes = Scopes::empty();
326        scopes.push("org", "123").unwrap();
327        scopes.push("project", "456").unwrap();
328
329        assert_eq!(scopes.get("org").unwrap().value(), "123");
330        assert_eq!(scopes.get("project").unwrap().value(), "456");
331        assert!(scopes.get("missing").is_none());
332    }
333
334    #[test]
335    fn test_get_value() {
336        let mut scopes = Scopes::empty();
337        scopes.push("org", "123").unwrap();
338
339        assert_eq!(scopes.get_value("org"), Some("123"));
340        assert_eq!(scopes.get_value("missing"), None);
341    }
342
343    #[test]
344    fn test_push_validates() {
345        let mut scopes = Scopes::empty();
346        assert!(scopes.push("", "value").is_err());
347        assert!(scopes.push("key", "").is_err());
348        assert!(scopes.push("key/bad", "value").is_err());
349        assert!(scopes.is_empty());
350    }
351
352    #[test]
353    fn test_is_empty() {
354        let empty = Scopes::empty();
355        assert!(empty.is_empty());
356
357        let mut non_empty = Scopes::empty();
358        non_empty.push("org", "1").unwrap();
359        assert!(!non_empty.is_empty());
360    }
361}