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(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}