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