objectstore_server/
killswitches.rs

1//! Runtime killswitches for disabling access to specific object contexts.
2//!
3//! A [`Killswitch`] matches requests by usecase, scope values, and optionally a downstream
4//! service glob pattern. When any configured killswitch matches an incoming request, the server
5//! rejects it immediately without forwarding to the storage backend.
6//!
7//! Killswitches are part of [`crate::config::Config`] and take effect on the next request after
8//! a configuration reload — no server restart is required.
9
10use std::collections::BTreeMap;
11use std::sync::OnceLock;
12
13use globset::{Glob, GlobMatcher};
14use objectstore_service::id::ObjectContext;
15use serde::{Deserialize, Serialize};
16
17/// A list of killswitches that may disable access to certain object contexts.
18#[derive(Debug, Default, Deserialize, Serialize)]
19pub struct Killswitches(pub Vec<Killswitch>);
20
21impl Killswitches {
22    /// Returns `true` if any of the contained killswitches matches the given context.
23    pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool {
24        self.0.iter().any(|s| s.matches(context, service))
25    }
26}
27
28/// A killswitch that may disable access to certain object contexts.
29///
30/// Note that at least one of the fields should be set, or else the killswitch will match all
31/// contexts and discard all requests.
32#[derive(Debug, Deserialize, Serialize)]
33pub struct Killswitch {
34    /// Optional usecase to match.
35    ///
36    /// If `None`, matches any usecase.
37    #[serde(default)]
38    pub usecase: Option<String>,
39
40    /// Scopes to match.
41    ///
42    /// If empty, matches any scopes. Additional scopes in the context are ignored, so a killswitch
43    /// matches if all of the specified scopes are present in the request with matching values.
44    #[serde(default)]
45    pub scopes: BTreeMap<String, String>,
46
47    /// Optional service glob pattern to match.
48    ///
49    /// If `None`, matches any service (or absence of service header).
50    /// If specified, the request must have a matching `x-downstream-service` header.
51    #[serde(default)]
52    pub service: Option<String>,
53
54    /// Compiled glob matcher for the service pattern.
55    ///
56    /// This is lazily compiled on first use to avoid unwrap() calls and gracefully handle
57    /// invalid patterns by treating them as non-matches.
58    #[serde(skip)]
59    #[serde(default)]
60    pub service_matcher: OnceLock<Option<GlobMatcher>>,
61}
62
63impl PartialEq for Killswitch {
64    fn eq(&self, other: &Self) -> bool {
65        self.usecase == other.usecase
66            && self.scopes == other.scopes
67            && self.service == other.service
68        // Skip service_matcher in comparison since it's derived from service
69    }
70}
71
72impl Killswitch {
73    /// Returns `true` if this killswitch matches the given context and service.
74    pub fn matches(&self, context: &ObjectContext, service: Option<&str>) -> bool {
75        if let Some(ref switch_usecase) = self.usecase
76            && switch_usecase != &context.usecase
77        {
78            return false;
79        }
80
81        for (scope_name, scope_value) in &self.scopes {
82            match context.scopes.get_value(scope_name) {
83                Some(value) if value == scope_value => (),
84                _ => return false,
85            }
86        }
87
88        // Check service pattern if specified
89        if let Some(ref pattern) = self.service {
90            // If pattern is specified but no service header present, don't match
91            let Some(service_value) = service else {
92                return false;
93            };
94
95            let matcher = self
96                .service_matcher
97                .get_or_init(|| Glob::new(pattern).ok().map(|g| g.compile_matcher()));
98
99            match matcher {
100                Some(m) if m.is_match(service_value) => (),
101                _ => return false,
102            }
103        }
104
105        true
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use objectstore_types::scope::{Scope, Scopes};
112
113    use super::*;
114
115    #[test]
116    fn test_matches_empty() {
117        let switch = Killswitch {
118            usecase: None,
119            scopes: BTreeMap::new(),
120            service: None,
121            service_matcher: OnceLock::new(),
122        };
123
124        let context = ObjectContext {
125            usecase: "any".to_string(),
126            scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
127        };
128
129        assert!(switch.matches(&context, None));
130    }
131
132    #[test]
133    fn test_matches_usecase() {
134        let switch = Killswitch {
135            usecase: Some("test".to_string()),
136            scopes: BTreeMap::new(),
137            service: None,
138            service_matcher: OnceLock::new(),
139        };
140
141        let context = ObjectContext {
142            usecase: "test".to_string(),
143            scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
144        };
145        assert!(switch.matches(&context, Some("anyservice")));
146
147        // usecase differs
148        let context = ObjectContext {
149            usecase: "other".to_string(),
150            scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
151        };
152        assert!(!switch.matches(&context, Some("anyservice")));
153    }
154
155    #[test]
156    fn test_matches_scopes() {
157        let switch = Killswitch {
158            usecase: None,
159            scopes: BTreeMap::from([
160                ("org".to_string(), "123".to_string()),
161                ("project".to_string(), "456".to_string()),
162            ]),
163            service: None,
164            service_matcher: OnceLock::new(),
165        };
166
167        // match, ignoring extra scope
168        let context = ObjectContext {
169            usecase: "any".to_string(),
170            scopes: Scopes::from_iter([
171                Scope::create("org", "123").unwrap(),
172                Scope::create("project", "456").unwrap(),
173                Scope::create("extra", "789").unwrap(),
174            ]),
175        };
176        assert!(switch.matches(&context, Some("anyservice")));
177
178        // project differs
179        let context = ObjectContext {
180            usecase: "any".to_string(),
181            scopes: Scopes::from_iter([
182                Scope::create("org", "123").unwrap(),
183                Scope::create("project", "999").unwrap(),
184            ]),
185        };
186        assert!(!switch.matches(&context, Some("anyservice")));
187
188        // missing project
189        let context = ObjectContext {
190            usecase: "any".to_string(),
191            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
192        };
193        assert!(!switch.matches(&context, Some("anyservice")));
194    }
195
196    #[test]
197    fn test_matches_full() {
198        let switch = Killswitch {
199            usecase: Some("test".to_string()),
200            scopes: BTreeMap::from([("org".to_string(), "123".to_string())]),
201            service: Some("myservice-*".to_string()),
202            service_matcher: OnceLock::new(),
203        };
204
205        // match with all filters
206        let context = ObjectContext {
207            usecase: "test".to_string(),
208            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
209        };
210        assert!(switch.matches(&context, Some("myservice-prod")));
211
212        // usecase differs
213        let context = ObjectContext {
214            usecase: "other".to_string(),
215            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
216        };
217        assert!(!switch.matches(&context, Some("myservice-prod")));
218
219        // scope differs
220        let context = ObjectContext {
221            usecase: "test".to_string(),
222            scopes: Scopes::from_iter([Scope::create("org", "999").unwrap()]),
223        };
224        assert!(!switch.matches(&context, Some("myservice-prod")));
225
226        // service differs
227        let context = ObjectContext {
228            usecase: "test".to_string(),
229            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
230        };
231        assert!(!switch.matches(&context, Some("otherservice")));
232
233        // missing service header
234        let context = ObjectContext {
235            usecase: "test".to_string(),
236            scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
237        };
238        assert!(!switch.matches(&context, None));
239    }
240
241    #[test]
242    fn test_matches_service_exact() {
243        let switch = Killswitch {
244            usecase: None,
245            scopes: BTreeMap::new(),
246            service: Some("myservice".to_string()),
247            service_matcher: OnceLock::new(),
248        };
249
250        let context = ObjectContext {
251            usecase: "any".to_string(),
252            scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
253        };
254
255        assert!(switch.matches(&context, Some("myservice")));
256        assert!(!switch.matches(&context, Some("otherservice")));
257        assert!(!switch.matches(&context, None));
258    }
259
260    #[test]
261    fn test_matches_service_glob() {
262        let switch = Killswitch {
263            usecase: None,
264            scopes: BTreeMap::new(),
265            service: Some("myservice-*".to_string()),
266            service_matcher: OnceLock::new(),
267        };
268
269        let context = ObjectContext {
270            usecase: "any".to_string(),
271            scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
272        };
273
274        // Matches with glob pattern
275        assert!(switch.matches(&context, Some("myservice-prod")));
276        assert!(switch.matches(&context, Some("myservice-dev")));
277        assert!(switch.matches(&context, Some("myservice-staging")));
278
279        // Doesn't match different service
280        assert!(!switch.matches(&context, Some("otherservice")));
281        assert!(!switch.matches(&context, Some("otherservice-prod")));
282
283        // Doesn't match prefix without separator
284        assert!(!switch.matches(&context, Some("myservice")));
285    }
286
287    #[test]
288    fn test_matches_service_invalid_glob() {
289        let switch = Killswitch {
290            usecase: None,
291            scopes: BTreeMap::new(),
292            service: Some("[invalid".to_string()), // Invalid glob pattern
293            service_matcher: OnceLock::new(),
294        };
295
296        let context = ObjectContext {
297            usecase: "any".to_string(),
298            scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
299        };
300
301        // Invalid pattern should not match anything
302        assert!(!switch.matches(&context, Some("anyservice")));
303        assert!(!switch.matches(&context, Some("[invalid")));
304    }
305}