feat: complete user login admin keys and deployment docs

This commit is contained in:
boos
2026-05-19 10:06:52 +02:00
parent 812a7c6c07
commit 4fe84707e5
16 changed files with 1384 additions and 187 deletions
+130 -1
View File
@@ -8,7 +8,7 @@ use axum::{
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
use tracing::info;
@@ -501,3 +501,132 @@ fn build_account_extra_config(
.map_err(|err| AppError::Internal(err.into()))
}
}
// ── Account model availability ────────────────────────────────────────────────
#[derive(Debug, Serialize)]
struct ModelItem {
name: String,
#[serde(rename = "latencyMs")]
latency_ms: Option<i64>,
disabled: bool,
#[serde(rename = "isManual")]
is_manual: bool,
}
#[derive(Debug, Deserialize)]
pub struct AddManualModelsRequest {
pub models: Vec<String>,
}
/// GET /api/accounts/{id}/models
pub async fn get_account_models(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting models for account: {}", id);
// Account + site info
let row = sqlx::query(
"SELECT a.id, a.site_id, s.name AS site_name
FROM accounts a
JOIN sites s ON s.id = a.site_id
WHERE a.id = ?",
)
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
use sqlx::Row;
let site_id: i64 = row.get("site_id");
let site_name: String = row.get("site_name");
// Available models for this account
let availability: Vec<(String, Option<i64>, bool)> = sqlx::query_as(
"SELECT model_name, latency_ms, is_manual
FROM model_availability
WHERE account_id = ? AND available = 1
ORDER BY model_name ASC",
)
.bind(id)
.fetch_all(&*state.db)
.await?;
// Disabled models for this site
let disabled_names: std::collections::HashSet<String> =
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ?")
.bind(site_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.collect();
let models: Vec<ModelItem> = availability
.into_iter()
.map(|(name, latency_ms, is_manual)| {
let disabled = disabled_names.contains(&name);
ModelItem {
name,
latency_ms,
disabled,
is_manual,
}
})
.collect();
let total_count = models.len();
let disabled_count = models.iter().filter(|m| m.disabled).count();
Ok(Json(json!({
"siteId": site_id,
"siteName": site_name,
"models": models,
"totalCount": total_count,
"disabledCount": disabled_count,
})))
}
/// POST /api/accounts/{id}/models/manual
pub async fn add_account_manual_models(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<AddManualModelsRequest>,
) -> AppResult<Json<Value>> {
info!(
"Adding {} manual models for account: {}",
payload.models.len(),
id
);
// Verify account exists
sqlx::query_scalar::<_, i64>("SELECT 1 FROM accounts WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
let mut inserted = 0usize;
for model_name in payload
.models
.iter()
.map(|m| m.trim())
.filter(|m| !m.is_empty())
{
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available, is_manual, checked_at)
VALUES (?, ?, 1, 1, datetime('now'))
ON CONFLICT (account_id, model_name) DO UPDATE SET
available = 1,
is_manual = 1,
checked_at = datetime('now')",
)
.bind(id)
.bind(model_name)
.execute(&*state.db)
.await?;
inserted += 1;
}
Ok(Json(json!({ "inserted": inserted })))
}
+80 -10
View File
@@ -4,7 +4,7 @@ use crate::{
AppState,
};
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
Json,
};
@@ -13,6 +13,12 @@ use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct ListKeysQuery {
pub q: Option<String>,
pub user_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDownstreamKeyRequest {
pub name: String,
@@ -25,6 +31,7 @@ pub struct CreateDownstreamKeyRequest {
pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>,
pub user_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
@@ -39,6 +46,7 @@ pub struct UpdateDownstreamKeyRequest {
pub max_requests: Option<i64>,
pub supported_models: Option<Vec<String>>,
pub allowed_route_ids: Option<String>,
pub user_id: Option<i64>,
}
/// Helper to validate models from a JSON string against a Vec<String>
@@ -54,13 +62,32 @@ fn validate_supported_models(models: &[String]) -> AppResult<()> {
}
/// GET /api/downstream-api-keys
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing downstream API keys");
pub async fn list_downstream_keys(
State(state): State<AppState>,
Query(query): Query<ListKeysQuery>,
) -> AppResult<Json<Value>> {
info!("Listing downstream API keys with filter: {:?}", query);
let keys: Vec<DownstreamApiKey> =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
.fetch_all(&*state.db)
.await?;
let q = query.q.unwrap_or_default().trim().to_string();
let q_like = format!("%{}%", q);
let user_id = query.user_id;
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
r#"
SELECT * FROM downstream_api_keys
WHERE (? = '' OR name LIKE ? OR key LIKE ? OR tags LIKE ?)
AND (? IS NULL OR user_id = ?)
ORDER BY id DESC
"#,
)
.bind(&q)
.bind(&q_like)
.bind(&q_like)
.bind(&q_like)
.bind(user_id)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(
json!({ "success": true, "items": keys, "data": keys }),
@@ -79,7 +106,11 @@ pub async fn create_downstream_key(
}
// Auto-generate key
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
let key = if payload.user_id.is_some() {
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
} else {
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
};
// Serialize supported_models to JSON string if provided
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
@@ -95,7 +126,7 @@ pub async fn create_downstream_key(
let enabled = payload.enabled.unwrap_or(true);
let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(&payload.name)
.bind(&key)
@@ -108,6 +139,7 @@ pub async fn create_downstream_key(
.bind(payload.max_requests)
.bind(&supported_models)
.bind(&payload.allowed_route_ids)
.bind(payload.user_id)
.execute(&*state.db)
.await?;
@@ -149,7 +181,7 @@ pub async fn update_downstream_key(
};
sqlx::query(
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, user_id = ?, updated_at = datetime('now') WHERE id = ?"
)
.bind(payload.name.unwrap_or(existing.name))
.bind(payload.description.or(existing.description))
@@ -161,6 +193,7 @@ pub async fn update_downstream_key(
.bind(payload.max_requests.or(existing.max_requests))
.bind(&supported_models)
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
.bind(payload.user_id.or(existing.user_id))
.bind(id)
.execute(&*state.db)
.await?;
@@ -196,3 +229,40 @@ pub async fn delete_downstream_key(
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
/// POST /api/downstream-api-keys/{id}/reset
pub async fn reset_downstream_key(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Resetting downstream API key: {}", id);
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Downstream key {} not found", id)))?;
let new_key = if existing.user_id.is_some() {
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
} else {
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
};
sqlx::query(
"UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(&new_key)
.bind(id)
.execute(&*state.db)
.await?;
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
+30 -2
View File
@@ -8,7 +8,7 @@ use axum::{
http::HeaderMap,
middleware,
response::Response,
routing::{get, post, put},
routing::{get, patch, post, put},
Json, Router,
};
use serde_json::{json, Value};
@@ -79,6 +79,14 @@ pub fn routes(state: AppState) -> Router<AppState> {
get(accounts::get_account)
.put(accounts::update_account)
.delete(accounts::delete_account),
)
.route(
"/api/accounts/{id}/models",
get(accounts::get_account_models),
)
.route(
"/api/accounts/{id}/models/manual",
post(accounts::add_account_manual_models),
);
let tokens_routes = Router::new()
@@ -112,10 +120,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
)
.route(
"/api/downstream-api-keys/{id}/reset",
post(downstream_keys::reset_downstream_key),
)
.route(
"/api/downstream-keys/{id}",
put(downstream_keys::update_downstream_key)
.delete(downstream_keys::delete_downstream_key),
)
.route(
"/api/downstream-keys/{id}/reset",
post(downstream_keys::reset_downstream_key),
);
let settings_routes =
@@ -133,7 +149,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
.patch(users::update_user)
.delete(users::delete_user),
)
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
.route(
"/api/admin/users/{id}/keys",
get(users::list_user_keys).post(users::create_user_key),
)
.route(
"/api/admin/users/{id}/keys/{key_id}",
patch(users::update_user_key).delete(users::delete_user_key),
)
.route(
"/api/admin/users/{id}/keys/{key_id}/reset",
post(users::reset_user_key),
);
Router::new()
.merge(sites_routes)
+199 -1
View File
@@ -2,17 +2,24 @@
use crate::{
auth::password::hash_password,
db::models::user::User,
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct CreateUserApiKeyRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
@@ -501,3 +508,194 @@ async fn attach_usage_summary(state: &AppState, item: &mut UserResponse) -> AppR
item.last_7_days_cost = Some(last_7_days_cost);
Ok(())
}
// ── User API Key Management for Admin ──────────────────────────────────
/// GET /api/admin/users/{id}/keys
pub async fn list_user_keys(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Admin listing API keys for user: {}", id);
// Verify user exists
let _exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM users WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "data": keys, "keys": keys })))
}
/// POST /api/admin/users/{id}/keys
pub async fn create_user_key(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<CreateUserApiKeyRequest>,
) -> AppResult<(StatusCode, Json<Value>)> {
info!("Admin creating API key for user: {}", id);
let key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
let result = sqlx::query(
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
)
.bind(&payload.name)
.bind(&key)
.bind(id)
.bind(true)
.execute(&*state.db)
.await?;
let key_id = result.last_insert_rowid();
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
}
/// DELETE /api/admin/users/{id}/keys/{key_id}
pub async fn delete_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
) -> AppResult<Json<Value>> {
info!("Admin deleting API key {} for user {}", key_id, user_id);
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(key_id)
.bind(user_id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Key {} for user {} not found",
key_id, user_id
)));
}
Ok(Json(json!({ "data": { "deleted": true, "id": key_id } })))
}
/// POST /api/admin/users/{id}/keys/{key_id}/reset
pub async fn reset_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
) -> AppResult<Json<Value>> {
info!("Admin resetting API key {} for user {}", key_id, user_id);
let new_key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
let affected = sqlx::query("UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.bind(&new_key)
.bind(key_id)
.bind(user_id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Key {} for user {} not found",
key_id, user_id
)));
}
let api_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": api_key })))
}
/// PATCH /api/admin/users/{id}/keys/{key_id}
pub async fn update_user_key(
State(state): State<AppState>,
Path((user_id, key_id)): Path<(i64, i64)>,
Json(payload): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
info!("Admin updating API key {} for user {}", key_id, user_id);
let _existing: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?",
)
.bind(key_id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Key {} for user {} not found", key_id, user_id)))?;
// Build dynamic UPDATE
let mut sets: Vec<String> = vec![];
let mut params: Vec<serde_json::Value> = vec![];
if let Some(v) = payload.get("name").and_then(|v| v.as_str()) {
sets.push("name = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("description") {
sets.push("description = ?".to_string());
params.push(v.clone());
}
if let Some(v) = payload.get("enabled").and_then(|v| v.as_bool()) {
sets.push("enabled = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("expiresAt") {
sets.push("expires_at = ?".to_string());
params.push(v.clone());
}
if let Some(v) = payload.get("maxCost").and_then(|v| v.as_f64()) {
sets.push("max_cost = ?".to_string());
params.push(json!(v));
}
if let Some(v) = payload.get("maxRequests").and_then(|v| v.as_i64()) {
sets.push("max_requests = ?".to_string());
params.push(json!(v));
}
if !sets.is_empty() {
sets.push("updated_at = datetime('now')".to_string());
let sql = format!(
"UPDATE downstream_api_keys SET {} WHERE id = ? AND user_id = ?",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
for p in &params {
if let Some(s) = p.as_str() {
q = q.bind(s.to_owned());
} else if let Some(b) = p.as_bool() {
q = q.bind(b);
} else if let Some(f) = p.as_f64() {
q = q.bind(f);
} else if let Some(i) = p.as_i64() {
q = q.bind(i);
} else if p.is_null() {
q = q.bind(None::<String>);
}
}
q.bind(key_id).bind(user_id).execute(&*state.db).await?;
}
let updated: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(key_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": updated })))
}