Skip to content

Commit 1de7e03

Browse files
committed
feat: Implement static killswitches
1 parent 4defaa7 commit 1de7e03

File tree

4 files changed

+215
-10
lines changed

4 files changed

+215
-10
lines changed

objectstore-server/src/config.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ use secrecy::{CloneableSecret, SecretBox, SerializableSecret, zeroize::Zeroize};
4545
use serde::{Deserialize, Serialize};
4646
use tracing::level_filters::LevelFilter;
4747

48+
use crate::killswitches::Killswitches;
49+
4850
/// Environment variable prefix for all configuration options.
4951
const ENV_PREFIX: &str = "OS__";
5052

@@ -92,7 +94,7 @@ impl Zeroize for ConfigSecret {
9294
/// The `type` field in YAML or `__TYPE` in environment variables determines which variant is used.
9395
///
9496
/// Used in: [`Config::high_volume_storage`], [`Config::long_term_storage`]
95-
#[derive(Debug, Clone, Deserialize, Serialize)]
97+
#[derive(Debug, Deserialize, Serialize)]
9698
#[serde(tag = "type", rename_all = "lowercase")]
9799
pub enum Storage {
98100
/// Local filesystem storage backend (type `"filesystem"`).
@@ -306,7 +308,7 @@ pub enum Storage {
306308
/// Controls the threading behavior of the server's async runtime.
307309
///
308310
/// Used in: [`Config::runtime`]
309-
#[derive(Debug, Clone, Deserialize, Serialize)]
311+
#[derive(Debug, Deserialize, Serialize)]
310312
#[serde(default)]
311313
pub struct Runtime {
312314
/// Number of worker threads for the server runtime.
@@ -355,7 +357,7 @@ impl Default for Runtime {
355357
/// tracing. Sentry is disabled by default and only enabled when a DSN is provided.
356358
///
357359
/// Used in: [`Config::sentry`]
358-
#[derive(Debug, Clone, Deserialize, Serialize)]
360+
#[derive(Debug, Deserialize, Serialize)]
359361
pub struct Sentry {
360362
/// Sentry DSN (Data Source Name).
361363
///
@@ -607,7 +609,7 @@ mod display_fromstr {
607609
/// Controls the verbosity and format of log output. Logs are always written to stderr.
608610
///
609611
/// Used in: [`Config::logging`]
610-
#[derive(Debug, Clone, Deserialize, Serialize)]
612+
#[derive(Debug, Deserialize, Serialize)]
611613
pub struct Logging {
612614
/// Minimum log level to output.
613615
///
@@ -664,7 +666,7 @@ impl Default for Logging {
664666
/// Metrics configuration.
665667
///
666668
/// Configures submission of internal metrics to Datadog.
667-
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
669+
#[derive(Debug, Default, Deserialize, Serialize)]
668670
pub struct Metrics {
669671
/// Datadog [API key] for metrics.
670672
///
@@ -710,7 +712,7 @@ pub struct Metrics {
710712

711713
/// A key that may be used to verify a request's `Authorization` header and its
712714
/// associated permissions. May contain multiple key versions to facilitate rotation.
713-
#[derive(Debug, Clone, Deserialize, Serialize)]
715+
#[derive(Debug, Deserialize, Serialize)]
714716
pub struct AuthZVerificationKey {
715717
/// Versions of this key's key material which may be used to verify signatures.
716718
///
@@ -729,7 +731,7 @@ pub struct AuthZVerificationKey {
729731
}
730732

731733
/// Configuration for content-based authorization.
732-
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
734+
#[derive(Debug, Default, Deserialize, Serialize)]
733735
pub struct AuthZ {
734736
/// Whether to enforce content-based authorization or not.
735737
///
@@ -758,7 +760,7 @@ pub struct AuthZ {
758760
///
759761
/// See individual field documentation for details on each configuration option, including
760762
/// defaults and environment variables.
761-
#[derive(Debug, Clone, Deserialize, Serialize)]
763+
#[derive(Debug, Deserialize, Serialize)]
762764
pub struct Config {
763765
/// HTTP server bind address.
764766
///
@@ -864,6 +866,9 @@ pub struct Config {
864866
/// Controls the verification and enforcement of content-based access control based on the
865867
/// JWT in a request's `Authorization` header.
866868
pub auth: AuthZ,
869+
870+
/// A list of matchers for requests to discard without processing.
871+
pub killswitches: Killswitches,
867872
}
868873

869874
impl Default for Config {
@@ -883,6 +888,7 @@ impl Default for Config {
883888
sentry: Sentry::default(),
884889
metrics: Metrics::default(),
885890
auth: AuthZ::default(),
891+
killswitches: Killswitches::default(),
886892
}
887893
}
888894
}

objectstore-server/src/extractors/id.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,41 @@ use std::borrow::Cow;
33
use axum::extract::rejection::PathRejection;
44
use axum::extract::{FromRequestParts, Path};
55
use axum::http::request::Parts;
6+
use axum::response::{IntoResponse, Response};
67
use objectstore_service::id::{ObjectContext, ObjectId};
78
use objectstore_types::scope::{EMPTY_SCOPES, Scope, Scopes};
89
use serde::{Deserialize, de};
910

1011
use crate::extractors::Xt;
1112
use crate::state::ServiceState;
1213

14+
#[derive(Debug)]
15+
pub enum ObjectRejection {
16+
Path(PathRejection),
17+
Killswitched,
18+
}
19+
20+
impl IntoResponse for ObjectRejection {
21+
fn into_response(self) -> Response {
22+
match self {
23+
ObjectRejection::Path(rejection) => rejection.into_response(),
24+
ObjectRejection::Killswitched => (
25+
axum::http::StatusCode::FORBIDDEN,
26+
"Object access is disabled for this scope through killswitches",
27+
)
28+
.into_response(),
29+
}
30+
}
31+
}
32+
33+
impl From<PathRejection> for ObjectRejection {
34+
fn from(rejection: PathRejection) -> Self {
35+
ObjectRejection::Path(rejection)
36+
}
37+
}
38+
1339
impl FromRequestParts<ServiceState> for Xt<ObjectId> {
14-
type Rejection = PathRejection;
40+
type Rejection = ObjectRejection;
1541

1642
async fn from_request_parts(
1743
parts: &mut Parts,
@@ -23,6 +49,10 @@ impl FromRequestParts<ServiceState> for Xt<ObjectId> {
2349
populate_sentry_context(id.context());
2450
sentry::configure_scope(|s| s.set_extra("key", id.key().into()));
2551

52+
if state.config.killswitches.matches(id.context()) {
53+
return Err(ObjectRejection::Killswitched);
54+
}
55+
2656
Ok(Xt(id))
2757
}
2858
}
@@ -66,7 +96,7 @@ where
6696
}
6797

6898
impl FromRequestParts<ServiceState> for Xt<ObjectContext> {
69-
type Rejection = PathRejection;
99+
type Rejection = ObjectRejection;
70100

71101
async fn from_request_parts(
72102
parts: &mut Parts,
@@ -80,6 +110,10 @@ impl FromRequestParts<ServiceState> for Xt<ObjectContext> {
80110

81111
populate_sentry_context(&context);
82112

113+
if state.config.killswitches.matches(&context) {
114+
return Err(ObjectRejection::Killswitched);
115+
}
116+
83117
Ok(Xt(context))
84118
}
85119
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
use std::collections::BTreeMap;
2+
3+
use objectstore_service::id::ObjectContext;
4+
use serde::{Deserialize, Serialize};
5+
6+
/// A list of killswitches that may disable access to certain object contexts.
7+
#[derive(Debug, Default, Deserialize, Serialize)]
8+
pub struct Killswitches(Vec<Killswitch>);
9+
10+
impl Killswitches {
11+
pub fn matches(&self, context: &ObjectContext) -> bool {
12+
self.0.iter().any(|s| s.matches(context))
13+
}
14+
}
15+
16+
/// A killswitch that may disable access to certain object contexts.
17+
///
18+
/// Note that at least one of the fields should be set, or else the killswitch will match all
19+
/// contexts and discard all requests.
20+
#[derive(Debug, Deserialize, Serialize)]
21+
pub struct Killswitch {
22+
/// Optional usecase to match.
23+
///
24+
/// If `None`, matches any usecase.
25+
#[serde(default)]
26+
pub usecase: Option<String>,
27+
28+
/// Scopes to match.
29+
///
30+
/// If empty, matches any scopes. Additional scopes in the context are ignored, so a killswitch
31+
/// matches if all of the specified scopes are present in the request with matching values.
32+
#[serde(default)]
33+
pub scopes: BTreeMap<String, String>,
34+
}
35+
36+
impl Killswitch {
37+
pub fn matches(&self, context: &ObjectContext) -> bool {
38+
if let Some(ref switch_usecase) = self.usecase
39+
&& switch_usecase != &context.usecase
40+
{
41+
return false;
42+
}
43+
44+
for (scope_name, scope_value) in &self.scopes {
45+
match context.scopes.get_value(scope_name) {
46+
Some(value) if value == scope_value => (),
47+
_ => return false,
48+
}
49+
}
50+
51+
true
52+
}
53+
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use objectstore_types::scope::{Scope, Scopes};
58+
59+
use super::*;
60+
61+
#[test]
62+
fn test_matches_empty() {
63+
let switch = Killswitch {
64+
usecase: None,
65+
scopes: BTreeMap::new(),
66+
};
67+
68+
let context = ObjectContext {
69+
usecase: "any".to_string(),
70+
scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
71+
};
72+
73+
assert!(switch.matches(&context));
74+
}
75+
76+
#[test]
77+
fn test_matches_usecase() {
78+
let switch = Killswitch {
79+
usecase: Some("test".to_string()),
80+
scopes: BTreeMap::new(),
81+
};
82+
83+
let context = ObjectContext {
84+
usecase: "test".to_string(),
85+
scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
86+
};
87+
assert!(switch.matches(&context));
88+
89+
// usecase differs
90+
let context = ObjectContext {
91+
usecase: "other".to_string(),
92+
scopes: Scopes::from_iter([Scope::create("any", "value").unwrap()]),
93+
};
94+
assert!(!switch.matches(&context));
95+
}
96+
97+
#[test]
98+
fn test_matches_scopes() {
99+
let switch = Killswitch {
100+
usecase: None,
101+
scopes: BTreeMap::from([
102+
("org".to_string(), "123".to_string()),
103+
("project".to_string(), "456".to_string()),
104+
]),
105+
};
106+
107+
// match, ignoring extra scope
108+
let context = ObjectContext {
109+
usecase: "any".to_string(),
110+
scopes: Scopes::from_iter([
111+
Scope::create("org", "123").unwrap(),
112+
Scope::create("project", "456").unwrap(),
113+
Scope::create("extra", "789").unwrap(),
114+
]),
115+
};
116+
assert!(switch.matches(&context));
117+
118+
// project differs
119+
let context = ObjectContext {
120+
usecase: "any".to_string(),
121+
scopes: Scopes::from_iter([
122+
Scope::create("org", "123").unwrap(),
123+
Scope::create("project", "999").unwrap(),
124+
]),
125+
};
126+
assert!(!switch.matches(&context));
127+
128+
// missing project
129+
let context = ObjectContext {
130+
usecase: "any".to_string(),
131+
scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
132+
};
133+
assert!(!switch.matches(&context));
134+
}
135+
136+
#[test]
137+
fn test_matches_full() {
138+
let switch = Killswitch {
139+
usecase: Some("test".to_string()),
140+
scopes: BTreeMap::from([("org".to_string(), "123".to_string())]),
141+
};
142+
143+
// match
144+
let context = ObjectContext {
145+
usecase: "test".to_string(),
146+
scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
147+
};
148+
assert!(switch.matches(&context));
149+
150+
// usecase differs
151+
let context = ObjectContext {
152+
usecase: "other".to_string(),
153+
scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
154+
};
155+
assert!(!switch.matches(&context));
156+
157+
// scope differs
158+
let context = ObjectContext {
159+
usecase: "test".to_string(),
160+
scopes: Scopes::from_iter([Scope::create("org", "999").unwrap()]),
161+
};
162+
assert!(!switch.matches(&context));
163+
}
164+
}

objectstore-server/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ pub mod error;
1111
pub mod extractors;
1212
pub mod healthcheck;
1313
pub mod http;
14+
pub mod killswitches;
1415
pub mod observability;
1516
pub mod state;

0 commit comments

Comments
 (0)