feat: User API Keys routes migrated to Rust

- New user_api_keys.rs route module
- JWT-authenticated CRUD on downstream_api_keys scoped to user_id
- GET/POST/PATCH/DELETE /api/user-api-keys with ownership verification
- Uses existing downstream_api_keys table with user_id FK
- Supports all fields: name, tags, enabled, expires_at, max_cost, etc.
- 18/18 tests pass
This commit is contained in:
boos
2026-05-17 13:37:17 +08:00
parent 9329248e59
commit d7d3670f02
2 changed files with 242 additions and 0 deletions
+2
View File
@@ -9,6 +9,7 @@ mod events;
mod health;
mod proxy;
mod site_announcements;
mod user_api_keys;
pub fn build_router(state: AppState) -> Router {
Router::new()
@@ -20,5 +21,6 @@ pub fn build_router(state: AppState) -> Router {
.merge(events::routes())
.merge(checkin::routes())
.merge(site_announcements::routes())
.merge(user_api_keys::routes())
.with_state(state)
}
+240
View File
@@ -0,0 +1,240 @@
// User-level API key routes — JWT-authenticated CRUD scoped to current user.
// Backed by the same downstream_api_keys table as the admin route.
use crate::{
auth::jwt::validate_jwt,
db::models::downstream_api_key::DownstreamApiKey,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/user-api-keys", get(list_user_keys).post(create_user_key))
.route("/api/user-api-keys/{id}", patch(update_user_key).delete(delete_user_key))
}
/// Extract user_id and role from JWT in Authorization header.
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user_id: i64 = claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
Ok((user_id, claims.role))
}
/// GET /api/user-api-keys — list keys for the authenticated user
async fn list_user_keys(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Listing user API keys for user {}", user_id);
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC"
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "keys": keys })))
}
/// POST /api/user-api-keys — create a new API key for the authenticated user
async fn create_user_key(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Creating user API key for user {}", user_id);
let name = body
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
let description = body.get("description").and_then(|v| v.as_str());
let tags = body.get("tags").map(|v| v.to_string());
// Generate key: sk-user-<uuid>
let key_str = body
.get("key")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("sk-user-{}", uuid::Uuid::new_v4().to_string().replace('-', "")));
let enabled = body.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let expires_at = body.get("expiresAt").and_then(|v| v.as_str());
let max_cost = body.get("maxCost").and_then(|v| v.as_f64());
let max_requests = body.get("maxRequests").and_then(|v| v.as_i64());
let supported_models = body.get("supportedModels").map(|v| v.to_string());
let allowed_route_ids = body.get("allowedRouteIds").map(|v| v.to_string());
let site_weight_multipliers = body.get("siteWeightMultipliers").map(|v| v.to_string());
let excluded_site_ids = body.get("excludedSiteIds").map(|v| v.to_string());
let excluded_credential_refs = body.get("excludedCredentialRefs").map(|v| v.to_string());
let result = sqlx::query(
r#"INSERT INTO downstream_api_keys
(name, key, description, tags, enabled, expires_at, max_cost, max_requests,
supported_models, allowed_route_ids, site_weight_multipliers,
excluded_site_ids, excluded_credential_refs, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#
)
.bind(name)
.bind(&key_str)
.bind(description)
.bind(tags)
.bind(enabled)
.bind(expires_at)
.bind(max_cost)
.bind(max_requests)
.bind(supported_models)
.bind(allowed_route_ids)
.bind(site_weight_multipliers)
.bind(excluded_site_ids)
.bind(excluded_credential_refs)
.bind(user_id)
.execute(&*state.db)
.await?;
let new_key: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ?"
)
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "key": new_key }))))
}
/// PATCH /api/user-api-keys/{id} — update a key owned by the authenticated user
async fn update_user_key(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Updating user API key {} for user {}", id, user_id);
// Verify ownership
let existing: Option<DownstreamApiKey> = sqlx::query_as(
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?"
)
.bind(id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?;
if existing.is_none() {
return Err(AppError::NotFound("Key not found".into()));
}
// Build dynamic UPDATE
let mut sets: Vec<String> = vec![];
let mut params: Vec<String> = vec![];
let mut bind_idx = 0;
if let Some(v) = body.get("name").and_then(|v| v.as_str()) {
bind_idx += 1; sets.push(format!("name = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("description") {
bind_idx += 1; sets.push(format!("description = ?{}", bind_idx));
params.push(v.as_str().unwrap_or_default().to_string());
}
if let Some(v) = body.get("tags") {
bind_idx += 1; sets.push(format!("tags = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
bind_idx += 1; sets.push(format!("enabled = ?{}", bind_idx)); params.push(if v { "1" } else { "0" }.to_string());
}
if let Some(v) = body.get("expiresAt").and_then(|v| v.as_str()) {
bind_idx += 1; sets.push(format!("expires_at = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("maxCost").and_then(|v| v.as_f64()) {
bind_idx += 1; sets.push(format!("max_cost = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("maxRequests").and_then(|v| v.as_i64()) {
bind_idx += 1; sets.push(format!("max_requests = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("supportedModels") {
bind_idx += 1; sets.push(format!("supported_models = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("allowedRouteIds") {
bind_idx += 1; sets.push(format!("allowed_route_ids = ?{}", bind_idx)); params.push(v.to_string());
}
if let Some(v) = body.get("excludedSiteIds") {
bind_idx += 1; sets.push(format!("excluded_site_ids = ?{}", bind_idx)); params.push(v.to_string());
}
if !sets.is_empty() {
bind_idx += 1;
sets.push(format!("updated_at = ?{}", bind_idx));
params.push(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string());
let sql = format!(
"UPDATE downstream_api_keys SET {} WHERE id = ?{} AND user_id = ?{}",
sets.join(", "),
bind_idx + 1,
bind_idx + 2
);
let mut q = sqlx::query(&sql);
for p in &params {
q = q.bind(p);
}
q = q.bind(id).bind(user_id);
q.execute(&*state.db).await?;
}
let updated: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ?"
)
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "key": updated })))
}
/// DELETE /api/user-api-keys/{id} — delete a key owned by the authenticated user
async fn delete_user_key(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<StatusCode> {
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
info!("Deleting user API key {} for user {}", id, user_id);
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Key not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}