Skip to content

Commit 1c628c2

Browse files
committed
Better API key UX
1 parent 62710a2 commit 1c628c2

File tree

16 files changed

+2251
-353
lines changed

16 files changed

+2251
-353
lines changed

src/config/limits.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,19 @@ pub struct ResourceLimits {
4747
/// Set to 0 for unlimited. Default: 10 providers per user.
4848
#[serde(default = "default_max_providers_per_user")]
4949
pub max_providers_per_user: u32,
50+
51+
/// Maximum API keys per user (self-service).
52+
/// Set to 0 for unlimited. Default: 25 keys per user.
53+
#[serde(default = "default_max_api_keys_per_user")]
54+
pub max_api_keys_per_user: u32,
5055
}
5156

5257
impl Default for ResourceLimits {
5358
fn default() -> Self {
5459
Self {
5560
max_policies_per_org: default_max_policies_per_org(),
5661
max_providers_per_user: default_max_providers_per_user(),
62+
max_api_keys_per_user: default_max_api_keys_per_user(),
5763
}
5864
}
5965
}
@@ -66,6 +72,10 @@ fn default_max_providers_per_user() -> u32 {
6672
10
6773
}
6874

75+
fn default_max_api_keys_per_user() -> u32 {
76+
25
77+
}
78+
6979
/// Rate limiting defaults.
7080
#[derive(Debug, Clone, Serialize, Deserialize)]
7181
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]

src/models/api_key.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,28 @@ pub struct CreateApiKey {
346346
pub rate_limit_tpm: Option<i32>,
347347
}
348348

349+
/// Self-service API key creation request (owner auto-set to current user).
350+
#[derive(Debug, Clone, Deserialize, Validate)]
351+
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
352+
pub struct CreateSelfServiceApiKey {
353+
#[validate(length(min = 1, max = 255))]
354+
pub name: String,
355+
/// Budget limit in cents
356+
pub budget_limit_cents: Option<i64>,
357+
pub budget_period: Option<BudgetPeriod>,
358+
pub expires_at: Option<DateTime<Utc>>,
359+
/// Permission scopes (null = full access)
360+
pub scopes: Option<Vec<String>>,
361+
/// Allowed models (null = all models)
362+
pub allowed_models: Option<Vec<String>>,
363+
/// IP allowlist in CIDR notation (null = all IPs)
364+
pub ip_allowlist: Option<Vec<String>>,
365+
/// Requests per minute override
366+
pub rate_limit_rpm: Option<i32>,
367+
/// Tokens per minute override
368+
pub rate_limit_tpm: Option<i32>,
369+
}
370+
349371
/// Returned on creation only (contains the raw key)
350372
#[derive(Debug, Clone, Serialize)]
351373
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]

src/openapi.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,12 @@ requests_per_minute = 120
397397
admin::me_providers::test_connectivity,
398398
admin::me_providers::test_credentials,
399399
admin::me_providers::built_in_providers,
400+
// Self-service endpoints - API Keys
401+
admin::me_api_keys::get,
402+
admin::me_api_keys::list,
403+
admin::me_api_keys::create,
404+
admin::me_api_keys::revoke,
405+
admin::me_api_keys::rotate,
400406
// Admin routes - Organizations
401407
admin::organizations::create,
402408
admin::organizations::get,
@@ -801,6 +807,7 @@ requests_per_minute = 120
801807
models::DynamicProviderResponse,
802808
models::CreateDynamicProvider,
803809
models::CreateSelfServiceProvider,
810+
models::CreateSelfServiceApiKey,
804811
models::UpdateDynamicProvider,
805812
models::ConnectivityTestResponse,
806813
models::ProviderOwner,

src/routes/admin/api_keys.rs

Lines changed: 120 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)