objectstore_server/
killswitches.rs

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