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