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 health;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
|
mod user_api_keys;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -20,5 +21,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(events::routes())
|
.merge(events::routes())
|
||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
|
.merge(user_api_keys::routes())
|
||||||
.with_state(state)
|
.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