@@ -55,6 +55,113 @@ fn get_services(state: &AppState) -> Result<&Services, AdminError> {
5555 state. services . as_ref ( ) . ok_or ( AdminError :: ServicesRequired )
5656}
5757
58+ /// Validate API key input fields (scopes, models, IPs, rate limits).
59+ /// Shared by both admin and self-service create endpoints.
60+ pub ( super ) fn validate_api_key_input (
61+ scopes : Option < & Vec < String > > ,
62+ allowed_models : Option < & Vec < String > > ,
63+ ip_allowlist : Option < & Vec < String > > ,
64+ rate_limit_rpm : Option < i32 > ,
65+ rate_limit_tpm : Option < i32 > ,
66+ rate_limits_config : & crate :: config:: RateLimitDefaults ,
67+ ) -> Result < ( ) , AdminError > {
68+ if let Some ( scopes) = scopes
69+ && let Err ( invalid_scopes) = validate_scopes ( scopes)
70+ {
71+ return Err ( AdminError :: Validation ( format ! (
72+ "Invalid scopes: {}. Valid scopes: {}" ,
73+ invalid_scopes. join( ", " ) ,
74+ ApiKeyScope :: all_names( ) . join( ", " )
75+ ) ) ) ;
76+ }
77+
78+ if let Some ( patterns) = allowed_models
79+ && let Err ( invalid_patterns) = validate_model_patterns ( patterns)
80+ {
81+ return Err ( AdminError :: Validation ( format ! (
82+ "Invalid model patterns: {}. Patterns must be non-empty and only support trailing wildcards (e.g., 'gpt-4*')." ,
83+ invalid_patterns. join( ", " )
84+ ) ) ) ;
85+ }
86+
87+ if let Some ( allowlist) = ip_allowlist
88+ && let Err ( invalid_entries) = validate_ip_allowlist ( allowlist)
89+ {
90+ return Err ( AdminError :: Validation ( format ! (
91+ "Invalid IP allowlist entries: {}. Entries must be valid IPs or CIDR notation (e.g., '192.168.1.0/24', '10.0.0.1')." ,
92+ invalid_entries. join( ", " )
93+ ) ) ) ;
94+ }
95+
96+ if let Some ( rpm) = rate_limit_rpm {
97+ if rpm <= 0 {
98+ return Err ( AdminError :: Validation (
99+ "rate_limit_rpm must be a positive integer" . to_string ( ) ,
100+ ) ) ;
101+ }
102+ if !rate_limits_config. allow_per_key_above_global
103+ && ( rpm as u32 ) > rate_limits_config. requests_per_minute
104+ {
105+ return Err ( AdminError :: Validation ( format ! (
106+ "rate_limit_rpm ({}) cannot exceed global limit ({}). Set allow_per_key_above_global = true in config to override." ,
107+ rpm, rate_limits_config. requests_per_minute
108+ ) ) ) ;
109+ }
110+ }
111+ if let Some ( tpm) = rate_limit_tpm {
112+ if tpm <= 0 {
113+ return Err ( AdminError :: Validation (
114+ "rate_limit_tpm must be a positive integer" . to_string ( ) ,
115+ ) ) ;
116+ }
117+ if !rate_limits_config. allow_per_key_above_global
118+ && ( tpm as u32 ) > rate_limits_config. tokens_per_minute
119+ {
120+ return Err ( AdminError :: Validation ( format ! (
121+ "rate_limit_tpm ({}) cannot exceed global limit ({}). Set allow_per_key_above_global = true in config to override." ,
122+ tpm, rate_limits_config. tokens_per_minute
123+ ) ) ) ;
124+ }
125+ }
126+
127+ Ok ( ( ) )
128+ }
129+
130+ /// Invalidate all cache entries for an API key.
131+ /// Shared by revoke and rotate endpoints (both admin and self-service).
132+ pub ( super ) async fn invalidate_api_key_cache ( cache : & dyn crate :: cache:: Cache , key_id : uuid:: Uuid ) {
133+ use crate :: models:: BudgetPeriod ;
134+
135+ let id_cache_key = CacheKeys :: api_key_by_id ( key_id) ;
136+ let _ = cache. delete ( & id_cache_key) . await ;
137+
138+ let reverse_key = CacheKeys :: api_key_reverse ( key_id) ;
139+ if let Ok ( Some ( hash_bytes) ) = cache. get_bytes ( & reverse_key) . await
140+ && let Ok ( hash) = String :: from_utf8 ( hash_bytes)
141+ {
142+ let hash_cache_key = CacheKeys :: api_key ( & hash) ;
143+ let _ = cache. delete ( & hash_cache_key) . await ;
144+ }
145+ let _ = cache. delete ( & reverse_key) . await ;
146+
147+ let _ = cache. delete ( & CacheKeys :: rate_limit ( key_id, "minute" ) ) . await ;
148+ let _ = cache. delete ( & CacheKeys :: rate_limit ( key_id, "day" ) ) . await ;
149+ let _ = cache
150+ . delete ( & CacheKeys :: rate_limit_tokens ( key_id, "minute" ) )
151+ . await ;
152+ let _ = cache
153+ . delete ( & CacheKeys :: rate_limit_tokens ( key_id, "day" ) )
154+ . await ;
155+ let _ = cache. delete ( & CacheKeys :: concurrent_requests ( key_id) ) . await ;
156+
157+ let _ = cache
158+ . delete ( & CacheKeys :: spend ( key_id, BudgetPeriod :: Daily ) )
159+ . await ;
160+ let _ = cache
161+ . delete ( & CacheKeys :: spend ( key_id, BudgetPeriod :: Monthly ) )
162+ . await ;
163+ }
164+
58165/// Create an API key
59166///
60167/// Creates a new API key scoped to an organization, project, or user. The raw API key value
@@ -154,36 +261,15 @@ pub async fn create(
154261 let services = get_services ( & state) ?;
155262 let actor = AuditActor :: from ( & admin_auth) ;
156263
157- // Validate scopes if provided
158- if let Some ( ref scopes) = input. scopes
159- && let Err ( invalid_scopes) = validate_scopes ( scopes)
160- {
161- return Err ( AdminError :: Validation ( format ! (
162- "Invalid scopes: {}. Valid scopes: {}" ,
163- invalid_scopes. join( ", " ) ,
164- ApiKeyScope :: all_names( ) . join( ", " )
165- ) ) ) ;
166- }
167-
168- // Validate allowed_models patterns if provided
169- if let Some ( ref patterns) = input. allowed_models
170- && let Err ( invalid_patterns) = validate_model_patterns ( patterns)
171- {
172- return Err ( AdminError :: Validation ( format ! (
173- "Invalid model patterns: {}. Patterns must be non-empty and only support trailing wildcards (e.g., 'gpt-4*')." ,
174- invalid_patterns. join( ", " )
175- ) ) ) ;
176- }
177-
178- // Validate ip_allowlist if provided
179- if let Some ( ref allowlist) = input. ip_allowlist
180- && let Err ( invalid_entries) = validate_ip_allowlist ( allowlist)
181- {
182- return Err ( AdminError :: Validation ( format ! (
183- "Invalid IP allowlist entries: {}. Entries must be valid IPs or CIDR notation (e.g., '192.168.1.0/24', '10.0.0.1')." ,
184- invalid_entries. join( ", " )
185- ) ) ) ;
186- }
264+ // Validate input fields
265+ validate_api_key_input (
266+ input. scopes . as_ref ( ) ,
267+ input. allowed_models . as_ref ( ) ,
268+ input. ip_allowlist . as_ref ( ) ,
269+ input. rate_limit_rpm ,
270+ input. rate_limit_tpm ,
271+ & state. config . limits . rate_limits ,
272+ ) ?;
187273
188274 // Authorization check for API key creation.
189275 // Each owner type requires permission scoped to the appropriate org/team/project.
@@ -253,39 +339,6 @@ pub async fn create(
253339 }
254340 }
255341
256- // Validate rate limits if provided (must be positive and within global limits unless override is enabled)
257- let rate_limits_config = & state. config . limits . rate_limits ;
258- if let Some ( rpm) = input. rate_limit_rpm {
259- if rpm <= 0 {
260- return Err ( AdminError :: Validation (
261- "rate_limit_rpm must be a positive integer" . to_string ( ) ,
262- ) ) ;
263- }
264- if !rate_limits_config. allow_per_key_above_global
265- && ( rpm as u32 ) > rate_limits_config. requests_per_minute
266- {
267- return Err ( AdminError :: Validation ( format ! (
268- "rate_limit_rpm ({}) cannot exceed global limit ({}). Set allow_per_key_above_global = true in config to override." ,
269- rpm, rate_limits_config. requests_per_minute
270- ) ) ) ;
271- }
272- }
273- if let Some ( tpm) = input. rate_limit_tpm {
274- if tpm <= 0 {
275- return Err ( AdminError :: Validation (
276- "rate_limit_tpm must be a positive integer" . to_string ( ) ,
277- ) ) ;
278- }
279- if !rate_limits_config. allow_per_key_above_global
280- && ( tpm as u32 ) > rate_limits_config. tokens_per_minute
281- {
282- return Err ( AdminError :: Validation ( format ! (
283- "rate_limit_tpm ({}) cannot exceed global limit ({}). Set allow_per_key_above_global = true in config to override." ,
284- tpm, rate_limits_config. tokens_per_minute
285- ) ) ) ;
286- }
287- }
288-
289342 // Get the key generation prefix from config
290343 let prefix = match & state. config . auth . gateway {
291344 GatewayAuthConfig :: ApiKey ( config) => config. generation_prefix ( ) ,
@@ -601,8 +654,6 @@ pub async fn revoke(
601654 headers : HeaderMap ,
602655 Path ( key_id) : Path < Uuid > ,
603656) -> Result < Json < ( ) > , AdminError > {
604- use crate :: models:: BudgetPeriod ;
605-
606657 authz. require (
607658 "api_key" ,
608659 "delete" ,
@@ -655,40 +706,7 @@ pub async fn revoke(
655706
656707 // Invalidate all cache entries for this API key
657708 if let Some ( cache) = & state. cache {
658- // Invalidate the ID-based cache entry
659- let id_cache_key = CacheKeys :: api_key_by_id ( key_id) ;
660- let _ = cache. delete ( & id_cache_key) . await ;
661-
662- // Invalidate the reverse lookup
663- let reverse_key = CacheKeys :: api_key_reverse ( key_id) ;
664- if let Ok ( Some ( hash_bytes) ) = cache. get_bytes ( & reverse_key) . await {
665- if let Ok ( hash) = String :: from_utf8 ( hash_bytes) {
666- // Invalidate the hash-based cache entry
667- let hash_cache_key = CacheKeys :: api_key ( & hash) ;
668- let _ = cache. delete ( & hash_cache_key) . await ;
669- }
670- }
671- let _ = cache. delete ( & reverse_key) . await ;
672-
673- // Invalidate rate limit entries
674- let _ = cache. delete ( & CacheKeys :: rate_limit ( key_id, "minute" ) ) . await ;
675- let _ = cache. delete ( & CacheKeys :: rate_limit ( key_id, "day" ) ) . await ;
676- let _ = cache
677- . delete ( & CacheKeys :: rate_limit_tokens ( key_id, "minute" ) )
678- . await ;
679- let _ = cache
680- . delete ( & CacheKeys :: rate_limit_tokens ( key_id, "day" ) )
681- . await ;
682- let _ = cache. delete ( & CacheKeys :: concurrent_requests ( key_id) ) . await ;
683-
684- // Invalidate spend tracking entries
685- let _ = cache
686- . delete ( & CacheKeys :: spend ( key_id, BudgetPeriod :: Daily ) )
687- . await ;
688- let _ = cache
689- . delete ( & CacheKeys :: spend ( key_id, BudgetPeriod :: Monthly ) )
690- . await ;
691-
709+ invalidate_api_key_cache ( cache. as_ref ( ) , key_id) . await ;
692710 tracing:: info!(
693711 api_key_id = %key_id,
694712 "Invalidated all cache entries for revoked API key"
@@ -699,9 +717,9 @@ pub async fn revoke(
699717}
700718
701719/// Default grace period for key rotation: 24 hours
702- const DEFAULT_GRACE_PERIOD_SECONDS : u64 = 86400 ;
720+ pub ( super ) const DEFAULT_GRACE_PERIOD_SECONDS : u64 = 86400 ;
703721/// Maximum grace period: 7 days
704- const MAX_GRACE_PERIOD_SECONDS : u64 = 604800 ;
722+ pub ( super ) const MAX_GRACE_PERIOD_SECONDS : u64 = 604800 ;
705723
706724/// Request body for rotating an API key
707725#[ derive( Debug , Clone , Deserialize ) ]
@@ -795,8 +813,6 @@ pub async fn rotate(
795813 Path ( key_id) : Path < Uuid > ,
796814 Json ( request) : Json < RotateApiKeyRequest > ,
797815) -> Result < ( StatusCode , Json < CreatedApiKey > ) , AdminError > {
798- use crate :: models:: BudgetPeriod ;
799-
800816 authz. require (
801817 "api_key" ,
802818 "update" ,
@@ -882,39 +898,7 @@ pub async fn rotate(
882898
883899 // Invalidate cache for old key
884900 if let Some ( cache) = & state. cache {
885- // Invalidate the ID-based cache entry
886- let id_cache_key = CacheKeys :: api_key_by_id ( key_id) ;
887- let _ = cache. delete ( & id_cache_key) . await ;
888-
889- // Invalidate the reverse lookup
890- let reverse_key = CacheKeys :: api_key_reverse ( key_id) ;
891- if let Ok ( Some ( hash_bytes) ) = cache. get_bytes ( & reverse_key) . await
892- && let Ok ( hash) = String :: from_utf8 ( hash_bytes)
893- {
894- let hash_cache_key = CacheKeys :: api_key ( & hash) ;
895- let _ = cache. delete ( & hash_cache_key) . await ;
896- }
897- let _ = cache. delete ( & reverse_key) . await ;
898-
899- // Invalidate rate limit entries (they will be re-created for the new key)
900- let _ = cache. delete ( & CacheKeys :: rate_limit ( key_id, "minute" ) ) . await ;
901- let _ = cache. delete ( & CacheKeys :: rate_limit ( key_id, "day" ) ) . await ;
902- let _ = cache
903- . delete ( & CacheKeys :: rate_limit_tokens ( key_id, "minute" ) )
904- . await ;
905- let _ = cache
906- . delete ( & CacheKeys :: rate_limit_tokens ( key_id, "day" ) )
907- . await ;
908- let _ = cache. delete ( & CacheKeys :: concurrent_requests ( key_id) ) . await ;
909-
910- // Invalidate spend tracking entries
911- let _ = cache
912- . delete ( & CacheKeys :: spend ( key_id, BudgetPeriod :: Daily ) )
913- . await ;
914- let _ = cache
915- . delete ( & CacheKeys :: spend ( key_id, BudgetPeriod :: Monthly ) )
916- . await ;
917-
901+ invalidate_api_key_cache ( cache. as_ref ( ) , key_id) . await ;
918902 tracing:: info!(
919903 old_key_id = %key_id,
920904 new_key_id = %created. api_key. id,
0 commit comments