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