Skip to content

Commit 1abe0de

Browse files
committed
refactor: rm sts_config
1 parent 35dd823 commit 1abe0de

6 files changed

Lines changed: 257 additions & 51 deletions

File tree

crates/core/src/config/static_file.rs

Lines changed: 241 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::error::ProxyError;
88
use crate::s3::response::BucketOwner;
99
use crate::types::{BucketConfig, RoleConfig, StoredCredential};
1010
use serde::Deserialize;
11+
use std::collections::HashSet;
1112
use std::sync::Arc;
1213

1314
/// Full configuration file structure.
@@ -25,6 +26,77 @@ pub struct StaticConfig {
2526
pub credentials: Vec<StoredCredential>,
2627
}
2728

29+
impl StaticConfig {
30+
/// Validate the configuration, collecting all errors into a single message.
31+
///
32+
/// Checks for:
33+
/// - Empty bucket names, role_ids, or credential access_key_ids
34+
/// - Duplicate bucket names, role_ids, or access_key_ids
35+
/// - Roles with empty `trusted_oidc_issuers` (would never accept a token)
36+
/// - `allowed_roles` referencing unknown role_ids (warning only, roles may come from a separate STS config)
37+
pub fn validate(&self) -> Result<(), ProxyError> {
38+
let mut errors = Vec::new();
39+
40+
// Check buckets
41+
let mut bucket_names = HashSet::new();
42+
for (i, bucket) in self.buckets.iter().enumerate() {
43+
if bucket.name.is_empty() {
44+
errors.push(format!("bucket[{}] has an empty name", i));
45+
} else if !bucket_names.insert(&bucket.name) {
46+
errors.push(format!("duplicate bucket name: {:?}", bucket.name));
47+
}
48+
}
49+
50+
// Check roles
51+
let mut role_ids = HashSet::new();
52+
for (i, role) in self.roles.iter().enumerate() {
53+
if role.role_id.is_empty() {
54+
errors.push(format!("role[{}] has an empty role_id", i));
55+
} else if !role_ids.insert(&role.role_id) {
56+
errors.push(format!("duplicate role_id: {:?}", role.role_id));
57+
}
58+
if role.trusted_oidc_issuers.is_empty() {
59+
errors.push(format!(
60+
"role {:?} has no trusted_oidc_issuers (will never accept a token)",
61+
role.role_id
62+
));
63+
}
64+
}
65+
66+
// Check credentials
67+
let mut access_key_ids = HashSet::new();
68+
for (i, cred) in self.credentials.iter().enumerate() {
69+
if cred.access_key_id.is_empty() {
70+
errors.push(format!("credential[{}] has an empty access_key_id", i));
71+
} else if !access_key_ids.insert(&cred.access_key_id) {
72+
errors.push(format!(
73+
"duplicate credential access_key_id: {:?}",
74+
cred.access_key_id
75+
));
76+
}
77+
}
78+
79+
// Warn about allowed_roles referencing unknown role_ids
80+
for bucket in &self.buckets {
81+
for role_ref in &bucket.allowed_roles {
82+
if !role_ids.contains(role_ref) {
83+
tracing::warn!(
84+
bucket = %bucket.name,
85+
role = %role_ref,
86+
"allowed_roles references unknown role_id (may be defined in a separate STS config)"
87+
);
88+
}
89+
}
90+
}
91+
92+
if errors.is_empty() {
93+
Ok(())
94+
} else {
95+
Err(ProxyError::ConfigError(errors.join("; ")))
96+
}
97+
}
98+
}
99+
28100
/// Configuration provider backed by a static TOML/JSON file.
29101
///
30102
/// # Example
@@ -45,11 +117,12 @@ pub struct StaticConfig {
45117
/// secret_access_key = "..."
46118
/// "#)?;
47119
/// ```
48-
#[derive(Clone)]
120+
#[derive(Clone, Debug)]
49121
pub struct StaticProvider {
50122
inner: Arc<StaticProviderInner>,
51123
}
52124

125+
#[derive(Debug)]
53126
struct StaticProviderInner {
54127
config: StaticConfig,
55128
}
@@ -59,14 +132,14 @@ impl StaticProvider {
59132
pub fn from_toml(toml_str: &str) -> Result<Self, ProxyError> {
60133
let config: StaticConfig =
61134
toml::from_str(toml_str).map_err(|e| ProxyError::ConfigError(e.to_string()))?;
62-
Ok(Self::from_config(config))
135+
Self::from_config(config)
63136
}
64137

65138
/// Parse a JSON string into a provider.
66139
pub fn from_json(json_str: &str) -> Result<Self, ProxyError> {
67140
let config: StaticConfig =
68141
serde_json::from_str(json_str).map_err(|e| ProxyError::ConfigError(e.to_string()))?;
69-
Ok(Self::from_config(config))
142+
Self::from_config(config)
70143
}
71144

72145
/// Read and parse a TOML file.
@@ -80,10 +153,11 @@ impl StaticProvider {
80153
}
81154
}
82155

83-
pub fn from_config(config: StaticConfig) -> Self {
84-
Self {
156+
pub fn from_config(config: StaticConfig) -> Result<Self, ProxyError> {
157+
config.validate()?;
158+
Ok(Self {
85159
inner: Arc::new(StaticProviderInner { config }),
86-
}
160+
})
87161
}
88162
}
89163

@@ -143,3 +217,164 @@ impl ConfigProvider for StaticProvider {
143217
.cloned())
144218
}
145219
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use super::*;
224+
225+
fn valid_config() -> StaticConfig {
226+
StaticConfig {
227+
owner_id: None,
228+
owner_display_name: None,
229+
buckets: vec![BucketConfig {
230+
name: "my-bucket".into(),
231+
backend_type: "s3".into(),
232+
backend_prefix: None,
233+
anonymous_access: true,
234+
allowed_roles: vec![],
235+
backend_options: Default::default(),
236+
}],
237+
roles: vec![RoleConfig {
238+
role_id: "my-role".into(),
239+
name: "My Role".into(),
240+
trusted_oidc_issuers: vec!["https://issuer.example.com".into()],
241+
required_audience: None,
242+
subject_conditions: vec![],
243+
allowed_scopes: vec![],
244+
max_session_duration_secs: 3600,
245+
}],
246+
credentials: vec![StoredCredential {
247+
access_key_id: "AKID1".into(),
248+
secret_access_key: "secret".into(),
249+
principal_name: "user".into(),
250+
allowed_scopes: vec![],
251+
created_at: chrono::Utc::now(),
252+
expires_at: None,
253+
enabled: true,
254+
}],
255+
}
256+
}
257+
258+
#[test]
259+
fn test_valid_config_passes_validation() {
260+
valid_config().validate().unwrap();
261+
}
262+
263+
#[test]
264+
fn test_empty_config_passes_validation() {
265+
let config = StaticConfig {
266+
owner_id: None,
267+
owner_display_name: None,
268+
buckets: vec![],
269+
roles: vec![],
270+
credentials: vec![],
271+
};
272+
config.validate().unwrap();
273+
}
274+
275+
#[test]
276+
fn test_empty_bucket_name() {
277+
let mut config = valid_config();
278+
config.buckets[0].name = "".into();
279+
let err = config.validate().unwrap_err().to_string();
280+
assert!(err.contains("bucket[0] has an empty name"), "{}", err);
281+
}
282+
283+
#[test]
284+
fn test_duplicate_bucket_names() {
285+
let mut config = valid_config();
286+
config.buckets.push(config.buckets[0].clone());
287+
let err = config.validate().unwrap_err().to_string();
288+
assert!(err.contains("duplicate bucket name"), "{}", err);
289+
}
290+
291+
#[test]
292+
fn test_empty_role_id() {
293+
let mut config = valid_config();
294+
config.roles[0].role_id = "".into();
295+
let err = config.validate().unwrap_err().to_string();
296+
assert!(err.contains("role[0] has an empty role_id"), "{}", err);
297+
}
298+
299+
#[test]
300+
fn test_duplicate_role_ids() {
301+
let mut config = valid_config();
302+
config.roles.push(config.roles[0].clone());
303+
let err = config.validate().unwrap_err().to_string();
304+
assert!(err.contains("duplicate role_id"), "{}", err);
305+
}
306+
307+
#[test]
308+
fn test_empty_trusted_oidc_issuers() {
309+
let mut config = valid_config();
310+
config.roles[0].trusted_oidc_issuers.clear();
311+
let err = config.validate().unwrap_err().to_string();
312+
assert!(err.contains("no trusted_oidc_issuers"), "{}", err);
313+
}
314+
315+
#[test]
316+
fn test_empty_access_key_id() {
317+
let mut config = valid_config();
318+
config.credentials[0].access_key_id = "".into();
319+
let err = config.validate().unwrap_err().to_string();
320+
assert!(
321+
err.contains("credential[0] has an empty access_key_id"),
322+
"{}",
323+
err
324+
);
325+
}
326+
327+
#[test]
328+
fn test_duplicate_access_key_ids() {
329+
let mut config = valid_config();
330+
config.credentials.push(config.credentials[0].clone());
331+
let err = config.validate().unwrap_err().to_string();
332+
assert!(
333+
err.contains("duplicate credential access_key_id"),
334+
"{}",
335+
err
336+
);
337+
}
338+
339+
#[test]
340+
fn test_multiple_errors_collected() {
341+
let mut config = valid_config();
342+
config.buckets[0].name = "".into();
343+
config.roles[0].role_id = "".into();
344+
config.credentials[0].access_key_id = "".into();
345+
let err = config.validate().unwrap_err().to_string();
346+
assert!(err.contains("bucket[0] has an empty name"), "{}", err);
347+
assert!(err.contains("role[0] has an empty role_id"), "{}", err);
348+
assert!(
349+
err.contains("credential[0] has an empty access_key_id"),
350+
"{}",
351+
err
352+
);
353+
}
354+
355+
#[test]
356+
fn test_from_config_runs_validation() {
357+
let mut config = valid_config();
358+
config.buckets[0].name = "".into();
359+
assert!(StaticProvider::from_config(config).is_err());
360+
}
361+
362+
#[test]
363+
fn test_from_toml_runs_validation() {
364+
let toml = r#"
365+
[[roles]]
366+
role_id = "bad-role"
367+
name = "Bad"
368+
max_session_duration_secs = 3600
369+
"#;
370+
let err = StaticProvider::from_toml(toml).unwrap_err().to_string();
371+
assert!(err.contains("no trusted_oidc_issuers"), "{}", err);
372+
}
373+
374+
#[test]
375+
fn test_from_json_runs_validation() {
376+
let json = r#"{"roles": [{"role_id": "bad-role", "name": "Bad", "max_session_duration_secs": 3600}]}"#;
377+
let err = StaticProvider::from_json(json).unwrap_err().to_string();
378+
assert!(err.contains("no trusted_oidc_issuers"), "{}", err);
379+
}
380+
}

examples/cf-workers/src/lib.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,14 @@ async fn fetch(req: web_sys::Request, env: Env, _ctx: Context) -> Result<web_sys
8585
// Build OIDC backend auth from env secrets/vars.
8686
let (oidc_auth, oidc_discovery) = load_oidc_auth(&env)?;
8787

88-
// Load STS config eagerly (config parsing is cheap).
89-
let sts_config = load_sts_config(&env)?;
90-
9188
let config = load_static_config(&env)?;
9289
let virtual_host_domain = env.var("VIRTUAL_HOST_DOMAIN").ok().map(|v| v.to_string());
93-
let resolver = DefaultResolver::new(config, virtual_host_domain, token_key.clone());
90+
let resolver = DefaultResolver::new(config.clone(), virtual_host_domain, token_key.clone());
9491

9592
// Build the gateway with route handlers
9693
let mut gateway = Gateway::new(WorkerBackend, resolver)
9794
.with_oidc_auth(oidc_auth)
98-
.with_route_handler(StsRouteHandler::new(sts_config, jwks_cache, token_key));
95+
.with_route_handler(StsRouteHandler::new(config, jwks_cache, token_key));
9996
if let Some(discovery) = oidc_discovery {
10097
gateway = gateway.with_route_handler(discovery);
10198
}
@@ -302,7 +299,8 @@ fn load_config_from_env(env: &Env, var_name: &str) -> Result<StaticProvider> {
302299
let static_config: StaticConfig = env
303300
.object_var(var_name)
304301
.map_err(|e| worker::Error::RustError(format!("{} config error: {}", var_name, e)))?;
305-
Ok(StaticProvider::from_config(static_config))
302+
StaticProvider::from_config(static_config)
303+
.map_err(|e| worker::Error::RustError(format!("{} config error: {}", var_name, e)))
306304
}
307305
}
308306

@@ -322,11 +320,6 @@ fn load_token_key(env: &Env) -> Result<Option<TokenKey>> {
322320
}
323321
}
324322

325-
/// Load STS config: tries STS_CONFIG first, falls back to PROXY_CONFIG.
326-
fn load_sts_config(env: &Env) -> Result<StaticProvider> {
327-
load_config_from_env(env, "STS_CONFIG").or_else(|_| load_config_from_env(env, "PROXY_CONFIG"))
328-
}
329-
330323
type OidcAuth = MaybeOidcAuth<FetchHttpExchange>;
331324

332325
/// Load OIDC provider config from env secrets/vars.

examples/cf-workers/wrangler.toml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ actions = [
7474
bucket = "private-uploads"
7575
prefixes = []
7676

77-
[vars.STS_CONFIG]
78-
[[vars.STS_CONFIG.roles]]
77+
[[vars.PROXY_CONFIG.roles]]
7978
max_session_duration_secs = 3600
8079
name = "Example User"
8180
role_id = "example-user"
@@ -84,17 +83,17 @@ trusted_oidc_issuers = [
8483
"https://auth.example.com",
8584
]
8685

87-
[[vars.STS_CONFIG.roles.allowed_scopes]]
86+
[[vars.PROXY_CONFIG.roles.allowed_scopes]]
8887
actions = ["get_object", "head_object", "put_object", "list_bucket"]
8988
bucket = "{sub}"
9089
prefixes = []
9190

92-
[[vars.STS_CONFIG.roles.allowed_scopes]]
91+
[[vars.PROXY_CONFIG.roles.allowed_scopes]]
9392
actions = ["get_object", "head_object", "put_object", "list_bucket"]
9493
bucket = "private-uploads"
9594
prefixes = []
9695

97-
[[vars.STS_CONFIG.roles]]
96+
[[vars.PROXY_CONFIG.roles]]
9897
max_session_duration_secs = 3600
9998
name = "GitHub Actions"
10099
role_id = "github-actions"
@@ -103,12 +102,12 @@ trusted_oidc_issuers = [
103102
"https://token.actions.githubusercontent.com",
104103
]
105104

106-
[[vars.STS_CONFIG.roles.allowed_scopes]]
105+
[[vars.PROXY_CONFIG.roles.allowed_scopes]]
107106
actions = ["get_object", "head_object", "list_bucket"]
108107
bucket = "cholmes"
109108
prefixes = []
110109

111-
[[vars.STS_CONFIG.roles]]
110+
[[vars.PROXY_CONFIG.roles]]
112111
max_session_duration_secs = 3600
113112
name = "GitHub Actions (No Access)"
114113
role_id = "github-actions-no-access"

0 commit comments

Comments
 (0)