Skip to main content

objectstore_server/
usecases.rs

1//! Configuration and validation for use case properties.
2//!
3//! Use cases are user-defined identifiers that namespace objects (e.g. `attachments`,
4//! `debug-files`). This module provides a central place to configure properties
5//! of use cases, such as which expiration policies are permitted and any duration
6//! caps.
7//!
8//! Unconfigured use cases receive the default configuration: all expiration
9//! policies are allowed with no duration caps.
10//!
11//! # YAML Configuration
12//!
13//! ```yaml
14//! usecases:
15//!   attachments:
16//!     expiration:
17//!       manual:
18//!         allowed: false
19//!       tti:
20//!         allowed: false
21//!       max: "90d"
22//!   debug-files:
23//!     expiration:
24//!       max: "90d"
25//! ```
26
27use std::collections::HashMap;
28use std::time::Duration;
29
30use objectstore_types::duration::format_duration;
31use objectstore_types::metadata::{ExpirationPolicy, Metadata};
32use objectstore_types::usecase::Usecase;
33use serde::{Deserialize, Serialize};
34use thiserror::Error;
35
36/// Configuration for all use case properties.
37///
38/// Maps validated use case names to their configuration. Invalid names are
39/// rejected when deserializing configuration. Use cases not present in the map
40/// receive the default configuration (all expiration policies allowed, no caps).
41#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
42pub struct UseCases(pub HashMap<Usecase, UseCaseConfig>);
43
44impl UseCases {
45    /// Validates metadata against the configuration for the given use case.
46    ///
47    /// Returns an error if any metadata field violates the use case's policy.
48    /// Use cases not present in the configuration are always valid.
49    pub fn validate(&self, usecase: &Usecase, metadata: &Metadata) -> Result<(), UseCaseError> {
50        if let Some(config) = self.0.get(usecase) {
51            config.validate(usecase, metadata)?
52        }
53        Ok(())
54    }
55
56    /// Returns the maximum allowed expiration duration for the given use case, if configured.
57    ///
58    /// Applies to both TTL and TTI expiration policies. `None` means no limit.
59    pub fn get_max_expiry(&self, usecase: &Usecase) -> Option<Duration> {
60        self.0.get(usecase).and_then(|config| config.expiration.max)
61    }
62}
63
64/// Configuration for a single use case.
65#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
66#[serde(default)]
67pub struct UseCaseConfig {
68    /// Expiration policy constraints for this use case.
69    pub expiration: ExpirationConfig,
70}
71
72impl UseCaseConfig {
73    fn validate(&self, usecase: &Usecase, metadata: &Metadata) -> Result<(), UseCaseError> {
74        let policy = metadata.expiration_policy;
75        let allowed = match policy {
76            ExpirationPolicy::Manual => self.expiration.manual.allowed,
77            ExpirationPolicy::TimeToLive(_) => self.expiration.ttl.allowed,
78            ExpirationPolicy::TimeToIdle(_) => self.expiration.tti.allowed,
79        };
80        if !allowed {
81            return Err(UseCaseError::PolicyNotAllowed {
82                usecase: usecase.to_owned(),
83                policy,
84            });
85        }
86
87        if let Some(max) = self.expiration.max
88            && let Some(duration) = policy.expires_in()
89            && duration > max
90        {
91            return Err(UseCaseError::DurationExceeded {
92                usecase: usecase.to_owned(),
93                duration: format_duration(duration).to_string(),
94                max: format_duration(max).to_string(),
95            });
96        }
97        Ok(())
98    }
99}
100
101/// Controls which expiration policies are allowed and their duration constraints.
102#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
103#[serde(default)]
104pub struct ExpirationConfig {
105    /// Configuration for the [`ExpirationPolicy::Manual`] policy.
106    pub manual: PolicyConfig,
107    /// Configuration for the [`ExpirationPolicy::TimeToLive`] policy.
108    pub ttl: PolicyConfig,
109    /// Configuration for the [`ExpirationPolicy::TimeToIdle`] policy.
110    pub tti: PolicyConfig,
111    /// Maximum allowed duration for TTL and TTI policies. `None` means no limit.
112    ///
113    /// When extending the lifetime of an existing object, this limit applies
114    /// from the request time regardless of the object's creation time.
115    #[serde(default, with = "humantime_serde")]
116    pub max: Option<Duration>,
117}
118
119/// Configuration for an expiration policy.
120#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
121#[serde(default)]
122pub struct PolicyConfig {
123    /// Whether this expiration policy is allowed. Defaults to `true`.
124    pub allowed: bool,
125}
126
127impl Default for PolicyConfig {
128    fn default() -> Self {
129        Self { allowed: true }
130    }
131}
132
133/// Errors produced when metadata violates a use case's configuration.
134#[derive(Debug, Error)]
135pub enum UseCaseError {
136    /// The expiration policy kind is not permitted for this use case.
137    #[error("expiration policy '{policy}' is not allowed for use case '{usecase}'")]
138    PolicyNotAllowed {
139        /// The use case name.
140        usecase: Usecase,
141        /// The disallowed policy, in wire format.
142        policy: ExpirationPolicy,
143    },
144
145    /// The expiration duration exceeds the maximum for this use case.
146    #[error("expiration duration {duration} exceeds maximum of {max} for use case '{usecase}'")]
147    DurationExceeded {
148        /// The use case name.
149        usecase: Usecase,
150        /// The requested duration, in wire format.
151        duration: String,
152        /// The configured maximum, in wire format.
153        max: String,
154    },
155}
156
157#[cfg(test)]
158mod tests {
159    use std::time::Duration;
160
161    use objectstore_types::metadata::{ExpirationPolicy, Metadata};
162
163    use super::*;
164
165    fn make_metadata(policy: ExpirationPolicy) -> Metadata {
166        Metadata {
167            expiration_policy: policy,
168            ..Metadata::default()
169        }
170    }
171
172    fn usecases_from(config: UseCaseConfig) -> UseCases {
173        let mut map = HashMap::new();
174        map.insert("test".parse().unwrap(), config);
175        UseCases(map)
176    }
177
178    #[test]
179    fn configuration_validates_usecase_keys() {
180        let config: UseCases = serde_json::from_str(r#"{"Debug_Files":{}}"#).unwrap();
181        assert!(config.0.contains_key("Debug_Files"));
182        assert!(!config.0.contains_key("debug_files"));
183        let roundtrip: UseCases =
184            serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
185        assert_eq!(roundtrip, config);
186
187        for name in ["_", "testing/org=999/injected"] {
188            let config = serde_json::json!({name: {}});
189            assert!(serde_json::from_value::<UseCases>(config).is_err());
190        }
191    }
192
193    // --- unconfigured use case ---
194
195    #[test]
196    fn unconfigured_usecase_allows_manual() {
197        let usecases = UseCases::default();
198        let metadata = make_metadata(ExpirationPolicy::Manual);
199        usecases
200            .validate(&"anything".parse().unwrap(), &metadata)
201            .unwrap();
202    }
203
204    #[test]
205    fn unconfigured_usecase_allows_ttl() {
206        let usecases = UseCases::default();
207        let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1)));
208        usecases
209            .validate(&"anything".parse().unwrap(), &metadata)
210            .unwrap();
211    }
212
213    #[test]
214    fn unconfigured_usecase_allows_tti() {
215        let usecases = UseCases::default();
216        let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(1)));
217        usecases
218            .validate(&"anything".parse().unwrap(), &metadata)
219            .unwrap();
220    }
221
222    // --- manual policy ---
223
224    #[test]
225    fn manual_disallowed_rejects() {
226        let usecases = usecases_from(UseCaseConfig {
227            expiration: ExpirationConfig {
228                manual: PolicyConfig { allowed: false },
229                ..ExpirationConfig::default()
230            },
231        });
232
233        let metadata = make_metadata(ExpirationPolicy::Manual);
234        let err = usecases
235            .validate(&"test".parse().unwrap(), &metadata)
236            .unwrap_err();
237        assert!(matches!(err, UseCaseError::PolicyNotAllowed { .. }));
238    }
239
240    #[test]
241    fn manual_allowed_passes() {
242        let usecases = usecases_from(UseCaseConfig {
243            expiration: ExpirationConfig {
244                manual: PolicyConfig { allowed: true },
245                max: Some(Duration::ZERO),
246                ..ExpirationConfig::default()
247            },
248        });
249
250        let metadata = make_metadata(ExpirationPolicy::Manual);
251        usecases
252            .validate(&"test".parse().unwrap(), &metadata)
253            .unwrap();
254    }
255
256    // --- ttl policy ---
257
258    #[test]
259    fn ttl_disallowed_rejects() {
260        let usecases = usecases_from(UseCaseConfig {
261            expiration: ExpirationConfig {
262                ttl: PolicyConfig { allowed: false },
263                ..ExpirationConfig::default()
264            },
265        });
266
267        let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1)));
268        let err = usecases
269            .validate(&"test".parse().unwrap(), &metadata)
270            .unwrap_err();
271        assert!(matches!(err, UseCaseError::PolicyNotAllowed { .. }));
272    }
273
274    #[test]
275    fn ttl_within_max_passes() {
276        let usecases = usecases_from(UseCaseConfig {
277            expiration: ExpirationConfig {
278                max: Some(Duration::from_hours(2)),
279                ..ExpirationConfig::default()
280            },
281        });
282
283        let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1)));
284        usecases
285            .validate(&"test".parse().unwrap(), &metadata)
286            .unwrap();
287    }
288
289    #[test]
290    fn ttl_at_max_passes() {
291        let usecases = usecases_from(UseCaseConfig {
292            expiration: ExpirationConfig {
293                max: Some(Duration::from_hours(1)),
294                ..ExpirationConfig::default()
295            },
296        });
297
298        let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1)));
299        usecases
300            .validate(&"test".parse().unwrap(), &metadata)
301            .unwrap();
302    }
303
304    #[test]
305    fn ttl_exceeds_max_rejects() {
306        let usecases = usecases_from(UseCaseConfig {
307            expiration: ExpirationConfig {
308                max: Some(Duration::from_hours(1)),
309                ..ExpirationConfig::default()
310            },
311        });
312
313        let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(2)));
314        let err = usecases
315            .validate(&"test".parse().unwrap(), &metadata)
316            .unwrap_err();
317        assert!(matches!(err, UseCaseError::DurationExceeded { .. }));
318    }
319
320    // --- tti policy ---
321
322    #[test]
323    fn tti_disallowed_rejects() {
324        let usecases = usecases_from(UseCaseConfig {
325            expiration: ExpirationConfig {
326                tti: PolicyConfig { allowed: false },
327                ..ExpirationConfig::default()
328            },
329        });
330
331        let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(1)));
332        let err = usecases
333            .validate(&"test".parse().unwrap(), &metadata)
334            .unwrap_err();
335        assert!(matches!(err, UseCaseError::PolicyNotAllowed { .. }));
336    }
337
338    #[test]
339    fn tti_within_max_passes() {
340        let usecases = usecases_from(UseCaseConfig {
341            expiration: ExpirationConfig {
342                max: Some(Duration::from_hours(2)),
343                ..ExpirationConfig::default()
344            },
345        });
346
347        let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(1)));
348        usecases
349            .validate(&"test".parse().unwrap(), &metadata)
350            .unwrap();
351    }
352
353    #[test]
354    fn tti_exceeds_max_rejects() {
355        let usecases = usecases_from(UseCaseConfig {
356            expiration: ExpirationConfig {
357                max: Some(Duration::from_hours(1)),
358                ..ExpirationConfig::default()
359            },
360        });
361
362        let metadata = make_metadata(ExpirationPolicy::TimeToIdle(Duration::from_hours(2)));
363        let err = usecases
364            .validate(&"test".parse().unwrap(), &metadata)
365            .unwrap_err();
366        assert!(matches!(err, UseCaseError::DurationExceeded { .. }));
367    }
368
369    // --- partial config ---
370
371    #[test]
372    fn other_policies_unaffected_when_only_tti_restricted() {
373        let usecases = usecases_from(UseCaseConfig {
374            expiration: ExpirationConfig {
375                tti: PolicyConfig { allowed: false },
376                ..ExpirationConfig::default()
377            },
378        });
379
380        let metadata = make_metadata(ExpirationPolicy::Manual);
381        usecases
382            .validate(&"test".parse().unwrap(), &metadata)
383            .unwrap();
384
385        let metadata = make_metadata(ExpirationPolicy::TimeToLive(Duration::from_hours(1)));
386        usecases
387            .validate(&"test".parse().unwrap(), &metadata)
388            .unwrap();
389    }
390}