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