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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ¶ms {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user