Files
GopherGate/src/dashboard/clients.rs
hobokenchicken 96486b6318
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled
security(dashboard): enforce admin authentication on all sensitive endpoints
This commit adds the missing auth::require_admin check to all analytics, system info, and configuration list endpoints. It also improves error logging in the usage summary handler to aid in troubleshooting 'Failed to load statistics' errors.
2026-03-07 00:07:14 +00:00

539 lines
18 KiB
Rust

use axum::{
extract::{Path, State},
response::Json,
};
use chrono;
use rand::Rng;
use serde::Deserialize;
use serde_json;
use sqlx::Row;
use tracing::warn;
use uuid;
use super::{ApiResponse, DashboardState};
/// Generate a random API token: sk-{48 hex chars}
fn generate_token() -> String {
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..24).map(|_| rng.random::<u8>()).collect();
format!("sk-{}", hex::encode(bytes))
}
#[derive(Deserialize)]
pub(super) struct CreateClientRequest {
pub(super) name: String,
pub(super) client_id: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct UpdateClientPayload {
pub(super) name: Option<String>,
pub(super) description: Option<String>,
pub(super) is_active: Option<bool>,
pub(super) rate_limit_per_minute: Option<i64>,
}
pub(super) async fn handle_get_clients(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
let result = sqlx::query(
r#"
SELECT
client_id as id,
name,
description,
created_at,
total_requests,
total_tokens,
total_cost,
is_active,
rate_limit_per_minute
FROM clients
ORDER BY created_at DESC
"#,
)
.fetch_all(pool)
.await;
match result {
Ok(rows) => {
let clients: Vec<serde_json::Value> = rows
.into_iter()
.map(|row| {
serde_json::json!({
"id": row.get::<String, _>("id"),
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
"description": row.get::<Option<String>, _>("description"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"requests_count": row.get::<i64, _>("total_requests"),
"total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"),
"status": if row.get::<bool, _>("is_active") { "active" } else { "inactive" },
"rate_limit_per_minute": row.get::<Option<i64>, _>("rate_limit_per_minute"),
})
})
.collect();
Json(ApiResponse::success(serde_json::json!(clients)))
}
Err(e) => {
warn!("Failed to fetch clients: {}", e);
Json(ApiResponse::error("Failed to fetch clients".to_string()))
}
}
}
pub(super) async fn handle_create_client(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Json(payload): Json<CreateClientRequest>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
let client_id = payload
.client_id
.unwrap_or_else(|| format!("client-{}", &uuid::Uuid::new_v4().to_string()[..8]));
let result = sqlx::query(
r#"
INSERT INTO clients (client_id, name, is_active)
VALUES (?, ?, TRUE)
RETURNING *
"#,
)
.bind(&client_id)
.bind(&payload.name)
.fetch_one(pool)
.await;
match result {
Ok(row) => {
// Auto-generate a token for the new client
let token = generate_token();
let token_result = sqlx::query(
"INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')",
)
.bind(&client_id)
.bind(&token)
.execute(pool)
.await;
if let Err(e) = token_result {
warn!("Client created but failed to generate token: {}", e);
}
Json(ApiResponse::success(serde_json::json!({
"id": row.get::<String, _>("client_id"),
"name": row.get::<Option<String>, _>("name"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"status": "active",
"token": token,
})))
}
Err(e) => {
warn!("Failed to create client: {}", e);
Json(ApiResponse::error(format!("Failed to create client: {}", e)))
}
}
}
pub(super) async fn handle_get_client(
State(state): State<DashboardState>,
Path(id): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool;
let result = sqlx::query(
r#"
SELECT
c.client_id as id,
c.name,
c.description,
c.is_active,
c.rate_limit_per_minute,
c.created_at,
COALESCE(c.total_tokens, 0) as total_tokens,
COALESCE(c.total_cost, 0.0) as total_cost,
COUNT(r.id) as total_requests,
MAX(r.timestamp) as last_request
FROM clients c
LEFT JOIN llm_requests r ON c.client_id = r.client_id
WHERE c.client_id = ?
GROUP BY c.client_id
"#,
)
.bind(&id)
.fetch_optional(pool)
.await;
match result {
Ok(Some(row)) => Json(ApiResponse::success(serde_json::json!({
"id": row.get::<String, _>("id"),
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
"description": row.get::<Option<String>, _>("description"),
"is_active": row.get::<bool, _>("is_active"),
"rate_limit_per_minute": row.get::<Option<i64>, _>("rate_limit_per_minute"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"),
"total_requests": row.get::<i64, _>("total_requests"),
"last_request": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_request"),
"status": if row.get::<bool, _>("is_active") { "active" } else { "inactive" },
}))),
Ok(None) => Json(ApiResponse::error(format!("Client '{}' not found", id))),
Err(e) => {
warn!("Failed to fetch client: {}", e);
Json(ApiResponse::error(format!("Failed to fetch client: {}", e)))
}
}
}
pub(super) async fn handle_update_client(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Path(id): Path<String>,
Json(payload): Json<UpdateClientPayload>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
// Build dynamic UPDATE query from provided fields
let mut sets = Vec::new();
let mut binds: Vec<String> = Vec::new();
if let Some(ref name) = payload.name {
sets.push("name = ?");
binds.push(name.clone());
}
if let Some(ref desc) = payload.description {
sets.push("description = ?");
binds.push(desc.clone());
}
if payload.is_active.is_some() {
sets.push("is_active = ?");
}
if payload.rate_limit_per_minute.is_some() {
sets.push("rate_limit_per_minute = ?");
}
if sets.is_empty() {
return Json(ApiResponse::error("No fields to update".to_string()));
}
// Always update updated_at
sets.push("updated_at = CURRENT_TIMESTAMP");
let sql = format!("UPDATE clients SET {} WHERE client_id = ?", sets.join(", "));
let mut query = sqlx::query(&sql);
// Bind in the same order as sets
for b in &binds {
query = query.bind(b);
}
if let Some(active) = payload.is_active {
query = query.bind(active);
}
if let Some(rate) = payload.rate_limit_per_minute {
query = query.bind(rate);
}
query = query.bind(&id);
match query.execute(pool).await {
Ok(result) => {
if result.rows_affected() == 0 {
return Json(ApiResponse::error(format!("Client '{}' not found", id)));
}
// Return the updated client
let row = sqlx::query(
r#"
SELECT client_id as id, name, description, is_active, rate_limit_per_minute,
created_at, total_requests, total_tokens, total_cost
FROM clients WHERE client_id = ?
"#,
)
.bind(&id)
.fetch_one(pool)
.await;
match row {
Ok(row) => Json(ApiResponse::success(serde_json::json!({
"id": row.get::<String, _>("id"),
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "Unnamed".to_string()),
"description": row.get::<Option<String>, _>("description"),
"is_active": row.get::<bool, _>("is_active"),
"rate_limit_per_minute": row.get::<Option<i64>, _>("rate_limit_per_minute"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"total_requests": row.get::<i64, _>("total_requests"),
"total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"),
"status": if row.get::<bool, _>("is_active") { "active" } else { "inactive" },
}))),
Err(e) => {
warn!("Failed to fetch updated client: {}", e);
// Update succeeded but fetch failed — still report success
Json(ApiResponse::success(serde_json::json!({ "message": "Client updated" })))
}
}
}
Err(e) => {
warn!("Failed to update client: {}", e);
Json(ApiResponse::error(format!("Failed to update client: {}", e)))
}
}
}
pub(super) async fn handle_delete_client(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Path(id): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
// Don't allow deleting the default client
if id == "default" {
return Json(ApiResponse::error("Cannot delete default client".to_string()));
}
let result = sqlx::query("DELETE FROM clients WHERE client_id = ?")
.bind(id)
.execute(pool)
.await;
match result {
Ok(_) => Json(ApiResponse::success(serde_json::json!({ "message": "Client deleted" }))),
Err(e) => Json(ApiResponse::error(format!("Failed to delete client: {}", e))),
}
}
pub(super) async fn handle_client_usage(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Path(id): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
// Get per-model breakdown for this client
let result = sqlx::query(
r#"
SELECT
model,
provider,
COUNT(*) as request_count,
SUM(prompt_tokens) as prompt_tokens,
SUM(completion_tokens) as completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(duration_ms) as avg_duration_ms
FROM llm_requests
WHERE client_id = ?
GROUP BY model, provider
ORDER BY total_cost DESC
"#,
)
.bind(&id)
.fetch_all(pool)
.await;
match result {
Ok(rows) => {
let breakdown: Vec<serde_json::Value> = rows
.into_iter()
.map(|row| {
serde_json::json!({
"model": row.get::<String, _>("model"),
"provider": row.get::<String, _>("provider"),
"request_count": row.get::<i64, _>("request_count"),
"prompt_tokens": row.get::<i64, _>("prompt_tokens"),
"completion_tokens": row.get::<i64, _>("completion_tokens"),
"total_tokens": row.get::<i64, _>("total_tokens"),
"total_cost": row.get::<f64, _>("total_cost"),
"avg_duration_ms": row.get::<f64, _>("avg_duration_ms"),
})
})
.collect();
Json(ApiResponse::success(serde_json::json!({
"client_id": id,
"breakdown": breakdown,
})))
}
Err(e) => {
warn!("Failed to fetch client usage: {}", e);
Json(ApiResponse::error(format!("Failed to fetch client usage: {}", e)))
}
}
}
// ── Token management endpoints ──────────────────────────────────────
pub(super) async fn handle_get_client_tokens(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Path(id): Path<String>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
let result = sqlx::query(
r#"
SELECT id, token, name, is_active, created_at, last_used_at
FROM client_tokens
WHERE client_id = ?
ORDER BY created_at DESC
"#,
)
.bind(&id)
.fetch_all(pool)
.await;
match result {
Ok(rows) => {
let tokens: Vec<serde_json::Value> = rows
.into_iter()
.map(|row| {
let token: String = row.get("token");
// Mask all but last 8 chars: sk-••••abcd1234
let masked = if token.len() > 8 {
format!("{}••••{}", &token[..3], &token[token.len() - 8..])
} else {
"••••".to_string()
};
serde_json::json!({
"id": row.get::<i64, _>("id"),
"token_masked": masked,
"name": row.get::<Option<String>, _>("name").unwrap_or_else(|| "default".to_string()),
"is_active": row.get::<bool, _>("is_active"),
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
"last_used_at": row.get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_used_at"),
})
})
.collect();
Json(ApiResponse::success(serde_json::json!(tokens)))
}
Err(e) => {
warn!("Failed to fetch client tokens: {}", e);
Json(ApiResponse::error(format!("Failed to fetch client tokens: {}", e)))
}
}
}
#[derive(Deserialize)]
pub(super) struct CreateTokenRequest {
pub(super) name: Option<String>,
}
pub(super) async fn handle_create_client_token(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Path(id): Path<String>,
Json(payload): Json<CreateTokenRequest>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
// Verify client exists
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 as x FROM clients WHERE client_id = ?")
.bind(&id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if exists.is_none() {
return Json(ApiResponse::error(format!("Client '{}' not found", id)));
}
let token = generate_token();
let token_name = payload.name.unwrap_or_else(|| "default".to_string());
let result = sqlx::query(
"INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?) RETURNING id, created_at",
)
.bind(&id)
.bind(&token)
.bind(&token_name)
.fetch_one(pool)
.await;
match result {
Ok(row) => Json(ApiResponse::success(serde_json::json!({
"id": row.get::<i64, _>("id"),
"token": token,
"name": token_name,
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at"),
}))),
Err(e) => {
warn!("Failed to create client token: {}", e);
Json(ApiResponse::error(format!("Failed to create token: {}", e)))
}
}
}
pub(super) async fn handle_delete_client_token(
State(state): State<DashboardState>,
headers: axum::http::HeaderMap,
Path((client_id, token_id)): Path<(String, i64)>,
) -> Json<ApiResponse<serde_json::Value>> {
let (_session, _) = match super::auth::require_admin(&state, &headers).await {
Ok((session, new_token)) => (session, new_token),
Err(e) => return e,
};
let pool = &state.app_state.db_pool;
let result = sqlx::query("DELETE FROM client_tokens WHERE id = ? AND client_id = ?")
.bind(token_id)
.bind(&client_id)
.execute(pool)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
Json(ApiResponse::error("Token not found".to_string()))
} else {
Json(ApiResponse::success(serde_json::json!({ "message": "Token revoked" })))
}
}
Err(e) => {
warn!("Failed to delete client token: {}", e);
Json(ApiResponse::error(format!("Failed to revoke token: {}", e)))
}
}
}