Skip to main content

objectstore_server/
usecases.rs

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