From e07377adc0d2e26a059a60d4afe43d7e411b7bad Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 2 Mar 2026 15:58:33 -0500 Subject: [PATCH] feat: add multi-user RBAC with admin/viewer roles and user management Add complete multi-user support with role-based access control: Backend: - Add users CRUD endpoints (GET/POST/PUT/DELETE /api/users) with admin-only guards - Add display_name column to users table with ALTER TABLE migration - Fix auth to use session-based user identity (not hardcoded 'admin') - Add POST /api/auth/logout to revoke server-side sessions - Add require_admin() and extract_session() helpers for clean RBAC - Guard all mutating endpoints (clients, providers, models, settings, backup) Frontend: - Add Users management page with create/edit/reset-password/delete modals - Add role gating: hide edit/delete buttons for viewers on clients, providers, models - Settings page hides auth tokens and admin actions for viewers - Logout now revokes server session before clearing localStorage - Sidebar shows real display_name and formatted role (Administrator/Viewer) - Fix sidebar header: single logo with onerror fallback, renamed to 'LLM Proxy' - Add badge and btn-action CSS classes for role pills and action buttons - Bump cache-bust to v=7 --- src/dashboard/auth.rs | 102 ++++++++++-- src/dashboard/clients.rs | 25 +++ src/dashboard/mod.rs | 10 ++ src/dashboard/models.rs | 5 + src/dashboard/providers.rs | 5 + src/dashboard/system.rs | 16 +- src/dashboard/users.rs | 287 ++++++++++++++++++++++++++++++++++ src/database/mod.rs | 6 + static/css/dashboard.css | 49 ++++++ static/index.html | 47 +++--- static/js/auth.js | 32 ++-- static/js/dashboard.js | 48 +++++- static/js/pages/clients.js | 2 + static/js/pages/models.js | 2 + static/js/pages/providers.js | 2 + static/js/pages/settings.js | 6 + static/js/pages/users.js | 290 +++++++++++++++++++++++++++++++++++ 17 files changed, 885 insertions(+), 49 deletions(-) create mode 100644 src/dashboard/users.rs create mode 100644 static/js/pages/users.js diff --git a/src/dashboard/auth.rs b/src/dashboard/auth.rs index 2b7d9e6a..3694b499 100644 --- a/src/dashboard/auth.rs +++ b/src/dashboard/auth.rs @@ -19,11 +19,12 @@ pub(super) async fn handle_login( ) -> Json> { let pool = &state.app_state.db_pool; - let user_result = - sqlx::query("SELECT username, password_hash, role, must_change_password FROM users WHERE username = ?") - .bind(&payload.username) - .fetch_optional(pool) - .await; + let user_result = sqlx::query( + "SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?", + ) + .bind(&payload.username) + .fetch_optional(pool) + .await; match user_result { Ok(Some(row)) => { @@ -31,6 +32,9 @@ pub(super) async fn handle_login( if bcrypt::verify(&payload.password, &hash).unwrap_or(false) { let username = row.get::("username"); let role = row.get::("role"); + let display_name = row + .get::, _>("display_name") + .unwrap_or_else(|| username.clone()); let must_change_password = row.get::("must_change_password"); let token = state .session_manager @@ -41,7 +45,7 @@ pub(super) async fn handle_login( "must_change_password": must_change_password, "user": { "username": username, - "name": "Administrator", + "name": display_name, "role": role } }))) @@ -69,11 +73,23 @@ pub(super) async fn handle_auth_status( if let Some(token) = token && let Some(session) = state.session_manager.validate_session(token).await { + // Look up display_name from DB + let display_name = sqlx::query_scalar::<_, Option>( + "SELECT display_name FROM users WHERE username = ?", + ) + .bind(&session.username) + .fetch_optional(&state.app_state.db_pool) + .await + .ok() + .flatten() + .flatten() + .unwrap_or_else(|| session.username.clone()); + return Json(ApiResponse::success(serde_json::json!({ "authenticated": true, "user": { "username": session.username, - "name": "Administrator", + "name": display_name, "role": session.role } }))); @@ -90,12 +106,29 @@ pub(super) struct ChangePasswordRequest { pub(super) async fn handle_change_password( State(state): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Json> { let pool = &state.app_state.db_pool; - // For now, always change 'admin' user - let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = 'admin'") + // Extract the authenticated user from the session token + let token = headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + + let session = match token { + Some(t) => state.session_manager.validate_session(t).await, + None => None, + }; + + let username = match session { + Some(s) => s.username, + None => return Json(ApiResponse::error("Not authenticated".to_string())), + }; + + let user_result = sqlx::query("SELECT password_hash FROM users WHERE username = ?") + .bind(&username) .fetch_one(pool) .await; @@ -109,9 +142,10 @@ pub(super) async fn handle_change_password( }; let update_result = sqlx::query( - "UPDATE users SET password_hash = ?, must_change_password = FALSE WHERE username = 'admin'", + "UPDATE users SET password_hash = ?, must_change_password = FALSE WHERE username = ?", ) .bind(new_hash) + .bind(&username) .execute(pool) .await; @@ -128,3 +162,51 @@ pub(super) async fn handle_change_password( Err(e) => Json(ApiResponse::error(format!("User not found: {}", e))), } } + +pub(super) async fn handle_logout( + State(state): State, + headers: axum::http::HeaderMap, +) -> Json> { + let token = headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + + if let Some(token) = token { + state.session_manager.revoke_session(token).await; + } + + Json(ApiResponse::success(serde_json::json!({ "message": "Logged out" }))) +} + +/// Helper: Extract and validate a session from the Authorization header. +/// Returns the Session if valid, or an error response. +pub(super) async fn extract_session( + state: &DashboardState, + headers: &axum::http::HeaderMap, +) -> Result>> { + let token = headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + + match token { + Some(t) => match state.session_manager.validate_session(t).await { + Some(session) => Ok(session), + None => Err(Json(ApiResponse::error("Session expired or invalid".to_string()))), + }, + None => Err(Json(ApiResponse::error("Not authenticated".to_string()))), + } +} + +/// Helper: Extract session and require admin role. +pub(super) async fn require_admin( + state: &DashboardState, + headers: &axum::http::HeaderMap, +) -> Result>> { + let session = extract_session(state, headers).await?; + if session.role != "admin" { + return Err(Json(ApiResponse::error("Admin access required".to_string()))); + } + Ok(session) +} diff --git a/src/dashboard/clients.rs b/src/dashboard/clients.rs index 7d8747d0..b3eebf95 100644 --- a/src/dashboard/clients.rs +++ b/src/dashboard/clients.rs @@ -85,8 +85,13 @@ pub(super) async fn handle_get_clients(State(state): State) -> J pub(super) async fn handle_create_client( State(state): State, + headers: axum::http::HeaderMap, Json(payload): Json, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; let client_id = payload @@ -189,9 +194,14 @@ pub(super) async fn handle_get_client( pub(super) async fn handle_update_client( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; // Build dynamic UPDATE query from provided fields @@ -281,8 +291,13 @@ pub(super) async fn handle_update_client( pub(super) async fn handle_delete_client( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; // Don't allow deleting the default client @@ -418,9 +433,14 @@ pub(super) struct CreateTokenRequest { pub(super) async fn handle_create_client_token( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; // Verify client exists @@ -462,8 +482,13 @@ pub(super) async fn handle_create_client_token( pub(super) async fn handle_delete_client_token( State(state): State, + headers: axum::http::HeaderMap, Path((client_id, token_id)): Path<(String, i64)>, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; let result = sqlx::query("DELETE FROM client_tokens WHERE id = ? AND client_id = ?") diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index c8e4b375..188dafcc 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -7,6 +7,7 @@ mod providers; pub mod sessions; mod system; mod usage; +mod users; mod websocket; use axum::{ @@ -67,7 +68,16 @@ pub fn router(state: AppState) -> Router { // API endpoints .route("/api/auth/login", post(auth::handle_login)) .route("/api/auth/status", get(auth::handle_auth_status)) + .route("/api/auth/logout", post(auth::handle_logout)) .route("/api/auth/change-password", post(auth::handle_change_password)) + .route( + "/api/users", + get(users::handle_get_users).post(users::handle_create_user), + ) + .route( + "/api/users/{id}", + put(users::handle_update_user).delete(users::handle_delete_user), + ) .route("/api/usage/summary", get(usage::handle_usage_summary)) .route("/api/usage/time-series", get(usage::handle_time_series)) .route("/api/usage/clients", get(usage::handle_clients_usage)) diff --git a/src/dashboard/models.rs b/src/dashboard/models.rs index 695b0d12..4ece2bb2 100644 --- a/src/dashboard/models.rs +++ b/src/dashboard/models.rs @@ -152,9 +152,14 @@ pub(super) async fn handle_get_models( pub(super) async fn handle_update_model( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(payload): Json, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; // Find provider_id for this model in registry diff --git a/src/dashboard/providers.rs b/src/dashboard/providers.rs index 5d9eb86c..d153acfa 100644 --- a/src/dashboard/providers.rs +++ b/src/dashboard/providers.rs @@ -238,9 +238,14 @@ pub(super) async fn handle_get_provider( pub(super) async fn handle_update_provider( State(state): State, + headers: axum::http::HeaderMap, Path(name): Path, Json(payload): Json, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; // Update or insert into database diff --git a/src/dashboard/system.rs b/src/dashboard/system.rs index a365d9a0..6c544506 100644 --- a/src/dashboard/system.rs +++ b/src/dashboard/system.rs @@ -275,7 +275,14 @@ pub(super) async fn handle_system_logs(State(state): State) -> J } } -pub(super) async fn handle_system_backup(State(state): State) -> Json> { +pub(super) async fn handle_system_backup( + State(state): State, + headers: axum::http::HeaderMap, +) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + let pool = &state.app_state.db_pool; let backup_id = format!("backup-{}", chrono::Utc::now().timestamp()); let backup_path = format!("data/{}.db", backup_id); @@ -331,8 +338,13 @@ pub(super) async fn handle_get_settings(State(state): State) -> } pub(super) async fn handle_update_settings( - State(_state): State, + State(state): State, + headers: axum::http::HeaderMap, ) -> Json> { + if let Err(e) = super::auth::require_admin(&state, &headers).await { + return e; + } + Json(ApiResponse::error( "Changing settings at runtime is not yet supported. Please update your config file and restart the server." .to_string(), diff --git a/src/dashboard/users.rs b/src/dashboard/users.rs new file mode 100644 index 00000000..4aec56ad --- /dev/null +++ b/src/dashboard/users.rs @@ -0,0 +1,287 @@ +use axum::{ + extract::{Path, State}, + response::Json, +}; +use serde::Deserialize; +use sqlx::Row; +use tracing::warn; + +use super::{ApiResponse, DashboardState, auth}; + +// ── User management endpoints (admin-only) ────────────────────────── + +pub(super) async fn handle_get_users( + State(state): State, + headers: axum::http::HeaderMap, +) -> Json> { + if let Err(e) = auth::require_admin(&state, &headers).await { + return e; + } + + let pool = &state.app_state.db_pool; + + let result = sqlx::query( + "SELECT id, username, display_name, role, must_change_password, created_at FROM users ORDER BY created_at ASC", + ) + .fetch_all(pool) + .await; + + match result { + Ok(rows) => { + let users: Vec = rows + .into_iter() + .map(|row| { + let username: String = row.get("username"); + let display_name: Option = row.get("display_name"); + serde_json::json!({ + "id": row.get::("id"), + "username": &username, + "display_name": display_name.as_deref().unwrap_or(&username), + "role": row.get::("role"), + "must_change_password": row.get::("must_change_password"), + "created_at": row.get::, _>("created_at"), + }) + }) + .collect(); + + Json(ApiResponse::success(serde_json::json!(users))) + } + Err(e) => { + warn!("Failed to fetch users: {}", e); + Json(ApiResponse::error("Failed to fetch users".to_string())) + } + } +} + +#[derive(Deserialize)] +pub(super) struct CreateUserRequest { + pub(super) username: String, + pub(super) password: String, + pub(super) display_name: Option, + pub(super) role: Option, +} + +pub(super) async fn handle_create_user( + State(state): State, + headers: axum::http::HeaderMap, + Json(payload): Json, +) -> Json> { + if let Err(e) = auth::require_admin(&state, &headers).await { + return e; + } + + let pool = &state.app_state.db_pool; + + // Validate role + let role = payload.role.as_deref().unwrap_or("viewer"); + if role != "admin" && role != "viewer" { + return Json(ApiResponse::error("Role must be 'admin' or 'viewer'".to_string())); + } + + // Validate username + let username = payload.username.trim(); + if username.is_empty() || username.len() > 64 { + return Json(ApiResponse::error("Username must be 1-64 characters".to_string())); + } + + // Validate password + if payload.password.len() < 4 { + return Json(ApiResponse::error("Password must be at least 4 characters".to_string())); + } + + let password_hash = match bcrypt::hash(&payload.password, 12) { + Ok(h) => h, + Err(_) => return Json(ApiResponse::error("Failed to hash password".to_string())), + }; + + let result = sqlx::query( + r#" + INSERT INTO users (username, password_hash, display_name, role, must_change_password) + VALUES (?, ?, ?, ?, TRUE) + RETURNING id, username, display_name, role, must_change_password, created_at + "#, + ) + .bind(username) + .bind(&password_hash) + .bind(&payload.display_name) + .bind(role) + .fetch_one(pool) + .await; + + match result { + Ok(row) => { + let uname: String = row.get("username"); + let display_name: Option = row.get("display_name"); + Json(ApiResponse::success(serde_json::json!({ + "id": row.get::("id"), + "username": &uname, + "display_name": display_name.as_deref().unwrap_or(&uname), + "role": row.get::("role"), + "must_change_password": row.get::("must_change_password"), + "created_at": row.get::, _>("created_at"), + }))) + } + Err(e) => { + let msg = if e.to_string().contains("UNIQUE") { + format!("Username '{}' already exists", username) + } else { + format!("Failed to create user: {}", e) + }; + warn!("Failed to create user: {}", e); + Json(ApiResponse::error(msg)) + } + } +} + +#[derive(Deserialize)] +pub(super) struct UpdateUserRequest { + pub(super) display_name: Option, + pub(super) role: Option, + pub(super) password: Option, + pub(super) must_change_password: Option, +} + +pub(super) async fn handle_update_user( + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, + Json(payload): Json, +) -> Json> { + if let Err(e) = auth::require_admin(&state, &headers).await { + return e; + } + + let pool = &state.app_state.db_pool; + + // Validate role if provided + if let Some(ref role) = payload.role { + if role != "admin" && role != "viewer" { + return Json(ApiResponse::error("Role must be 'admin' or 'viewer'".to_string())); + } + } + + // Build dynamic update + let mut sets = Vec::new(); + let mut string_binds: Vec = Vec::new(); + let mut has_password = false; + let mut has_must_change = false; + + if let Some(ref display_name) = payload.display_name { + sets.push("display_name = ?"); + string_binds.push(display_name.clone()); + } + if let Some(ref role) = payload.role { + sets.push("role = ?"); + string_binds.push(role.clone()); + } + if let Some(ref password) = payload.password { + if password.len() < 4 { + return Json(ApiResponse::error("Password must be at least 4 characters".to_string())); + } + let hash = match bcrypt::hash(password, 12) { + Ok(h) => h, + Err(_) => return Json(ApiResponse::error("Failed to hash password".to_string())), + }; + sets.push("password_hash = ?"); + string_binds.push(hash); + has_password = true; + } + if let Some(mcp) = payload.must_change_password { + sets.push("must_change_password = ?"); + has_must_change = true; + let _ = mcp; // used below in bind + } + + if sets.is_empty() { + return Json(ApiResponse::error("No fields to update".to_string())); + } + + let sql = format!("UPDATE users SET {} WHERE id = ?", sets.join(", ")); + let mut query = sqlx::query(&sql); + + for b in &string_binds { + query = query.bind(b); + } + if has_must_change { + query = query.bind(payload.must_change_password.unwrap()); + } + let _ = has_password; // consumed above via string_binds + query = query.bind(id); + + match query.execute(pool).await { + Ok(result) => { + if result.rows_affected() == 0 { + return Json(ApiResponse::error("User not found".to_string())); + } + // Fetch updated user + let row = sqlx::query( + "SELECT id, username, display_name, role, must_change_password, created_at FROM users WHERE id = ?", + ) + .bind(id) + .fetch_one(pool) + .await; + + match row { + Ok(row) => { + let uname: String = row.get("username"); + let display_name: Option = row.get("display_name"); + Json(ApiResponse::success(serde_json::json!({ + "id": row.get::("id"), + "username": &uname, + "display_name": display_name.as_deref().unwrap_or(&uname), + "role": row.get::("role"), + "must_change_password": row.get::("must_change_password"), + "created_at": row.get::, _>("created_at"), + }))) + } + Err(_) => Json(ApiResponse::success(serde_json::json!({ "message": "User updated" }))), + } + } + Err(e) => { + warn!("Failed to update user: {}", e); + Json(ApiResponse::error(format!("Failed to update user: {}", e))) + } + } +} + +pub(super) async fn handle_delete_user( + State(state): State, + headers: axum::http::HeaderMap, + Path(id): Path, +) -> Json> { + let session = match auth::require_admin(&state, &headers).await { + Ok(s) => s, + Err(e) => return e, + }; + + let pool = &state.app_state.db_pool; + + // Don't allow deleting yourself + let target_username: Option = + sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await + .unwrap_or(None); + + match target_username { + None => return Json(ApiResponse::error("User not found".to_string())), + Some(ref uname) if uname == &session.username => { + return Json(ApiResponse::error("Cannot delete your own account".to_string())); + } + _ => {} + } + + let result = sqlx::query("DELETE FROM users WHERE id = ?") + .bind(id) + .execute(pool) + .await; + + match result { + Ok(_) => Json(ApiResponse::success(serde_json::json!({ "message": "User deleted" }))), + Err(e) => { + warn!("Failed to delete user: {}", e); + Json(ApiResponse::error(format!("Failed to delete user: {}", e))) + } + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index bce8b016..bb8a652f 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -121,6 +121,7 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, + display_name TEXT, role TEXT DEFAULT 'admin', must_change_password BOOLEAN DEFAULT FALSE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -153,6 +154,11 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { .execute(pool) .await; + // Add display_name column if it doesn't exist (migration for existing DBs) + let _ = sqlx::query("ALTER TABLE users ADD COLUMN display_name TEXT") + .execute(pool) + .await; + // Add cache token columns if they don't exist (migration for existing DBs) let _ = sqlx::query("ALTER TABLE llm_requests ADD COLUMN cache_read_tokens INTEGER DEFAULT 0") .execute(pool) diff --git a/static/css/dashboard.css b/static/css/dashboard.css index dacf189b..74c13aa5 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -840,6 +840,24 @@ body { border: 1px solid var(--bg3); } +/* Role / status badges */ +.badge { + padding: 0.2rem 0.6rem; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 0.25rem; + letter-spacing: 0.03em; +} + +.badge-success { background: rgba(184, 187, 38, 0.2); color: var(--green-light); } +.badge-info { background: rgba(69, 133, 136, 0.2); color: var(--blue-light); } +.badge-warning { background: rgba(250, 189, 47, 0.2); color: var(--yellow-light); } +.badge-danger { background: rgba(251, 73, 52, 0.2); color: var(--red-light); } + /* Charts */ .chart-container { position: relative; @@ -907,6 +925,37 @@ body { .btn-danger { background: var(--red); color: var(--fg0); } .btn-danger:hover { background: var(--red-light); } +/* Small inline action buttons (edit, delete, copy) */ +.btn-action { + background: none; + border: 1px solid var(--bg3); + color: var(--fg3); + padding: 0.35rem 0.5rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-action:hover { + background: var(--bg2); + color: var(--fg0); + border-color: var(--bg4); +} + +.btn-action.danger:hover { + background: rgba(251, 73, 52, 0.15); + color: var(--red-light); + border-color: var(--red); +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + /* Modals */ .modal { position: fixed; diff --git a/static/index.html b/static/index.html index 2232205e..d0692345 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ LLM Proxy Gateway - Admin Dashboard - + @@ -57,9 +57,9 @@