@@ -8,6 +8,7 @@ use crate::error::ProxyError;
88use crate :: s3:: response:: BucketOwner ;
99use crate :: types:: { BucketConfig , RoleConfig , StoredCredential } ;
1010use serde:: Deserialize ;
11+ use std:: collections:: HashSet ;
1112use 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 ) ]
49121pub struct StaticProvider {
50122 inner : Arc < StaticProviderInner > ,
51123}
52124
125+ #[ derive( Debug ) ]
53126struct 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+ }
0 commit comments