From 2cdbf270f61f171fb95ec39ef22343486215231f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 14:22:57 +0000 Subject: [PATCH] Add multi-user security service with per-app authorization Control plane: - Users and app grants stored in SQLite (users + user_apps tables) - bcrypt password hashing - Sessions: HashMap (in-memory, cleared on restart) - Bootstrap: first admin auto-created from HIY_ADMIN_USER/HIY_ADMIN_PASS if DB is empty - /admin/users page: create/delete users, toggle admin, grant/revoke app access - /api/users + /api/users/:id/apps/:app_id REST endpoints (admin-only) Deployed apps: - Every app route now uses Caddy forward_auth pointing at /auth/verify - /auth/verify checks session cookie + user_apps grant (admins have access to all apps) - Unauthenticated -> 302 to /login?next= - Authorised but not granted -> /denied page - Session cookie set with Domain=.DOMAIN_SUFFIX for cross-subdomain auth Other: - /denied page for "logged in but not granted" case - Login page skips re-auth if already logged in - Cookie uses SameSite=Lax (required for cross-subdomain redirect flows) https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- builder/build.sh | 43 ++++- server/Cargo.toml | 1 + server/src/auth.rs | 313 +++++++++++++++++++++++++++++++------ server/src/db.rs | 22 +++ server/src/main.rs | 73 +++++++-- server/src/models.rs | 22 +++ server/src/routes/mod.rs | 1 + server/src/routes/ui.rs | 157 ++++++++++++++++++- server/src/routes/users.rs | 181 +++++++++++++++++++++ 9 files changed, 744 insertions(+), 69 deletions(-) create mode 100644 server/src/routes/users.rs diff --git a/builder/build.sh b/builder/build.sh index 6603cdd..a37baa8 100755 --- a/builder/build.sh +++ b/builder/build.sh @@ -127,13 +127,44 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then else ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes" - ROUTE_JSON=$(cat </dev/null || echo "[]") # Remove existing route for the same host, rebuild list, keep dashboard as catch-all. diff --git a/server/Cargo.toml b/server/Cargo.toml index 4ecb6ec..10f4432 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,5 +23,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } dotenvy = "0.15" async-stream = "0.3" +bcrypt = "0.15" anyhow = "1" futures = "0.3" diff --git a/server/src/auth.rs b/server/src/auth.rs index 9d4521d..9937892 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -1,51 +1,48 @@ use axum::{ extract::{Form, Query, Request, State}, - http::header, + http::{header, HeaderMap, StatusCode}, middleware::Next, response::{Html, IntoResponse, Redirect, Response}, }; use serde::Deserialize; -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -pub type Sessions = Arc>>; +/// token → user_id +pub type Sessions = Arc>>; pub fn new_sessions() -> Sessions { - Arc::new(Mutex::new(HashSet::new())) + Arc::new(Mutex::new(HashMap::new())) } -// ── Middleware ────────────────────────────────────────────────────────────── +// ── Helpers ───────────────────────────────────────────────────────────────── -pub async fn auth_middleware( - State(state): State, - request: Request, - next: Next, -) -> Response { - // Auth disabled when credentials are not configured. - if state.admin_user.is_none() { - return next.run(request).await; - } - - let token = session_cookie(request.headers()); - let authenticated = match token { - Some(t) => state.sessions.lock().await.contains(&t), - None => false, - }; - - if authenticated { - next.run(request).await +pub fn session_cookie_header(token: &str, domain_suffix: &str) -> String { + let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") { + String::new() } else { - let path = request - .uri() - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or("/"); - Redirect::to(&format!("/login?next={}", safe_path(path))).into_response() - } + format!("; Domain=.{}", domain_suffix) + }; + format!( + "session={}; HttpOnly; SameSite=Lax; Path={}{}", + token, "/", domain + ) } -fn session_cookie(headers: &axum::http::HeaderMap) -> Option { +fn clear_session_cookie(domain_suffix: &str) -> String { + let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") { + String::new() + } else { + format!("; Domain=.{}", domain_suffix) + }; + format!( + "session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", + domain + ) +} + +pub fn get_session_cookie(headers: &HeaderMap) -> Option { headers .get(header::COOKIE) .and_then(|v| v.to_str().ok()) @@ -57,6 +54,12 @@ fn session_cookie(headers: &axum::http::HeaderMap) -> Option { }) } +/// Resolve the user_id for the current request, if logged in. +pub async fn current_user_id(state: &crate::AppState, headers: &HeaderMap) -> Option { + let token = get_session_cookie(headers)?; + state.sessions.lock().await.get(&token).cloned() +} + /// Reject open-redirect: only allow relative paths. fn safe_path(next: &str) -> String { if next.starts_with('/') && !next.starts_with("//") { @@ -66,11 +69,153 @@ fn safe_path(next: &str) -> String { } } +// ── Control-plane middleware (admin users only) ─────────────────────────────── + +pub async fn auth_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + // Auth disabled when credentials are not configured (dev mode). + if state.admin_user.is_none() && !state.auth_enabled { + return next.run(request).await; + } + + let user_id = current_user_id(&state, request.headers()).await; + + let is_admin = match user_id { + None => false, + Some(uid) => { + sqlx::query_scalar::<_, i64>("SELECT is_admin FROM users WHERE id = ?") + .bind(&uid) + .fetch_optional(&state.db) + .await + .unwrap_or(None) + .map(|v| v != 0) + .unwrap_or(false) + } + }; + + if is_admin { + next.run(request).await + } else { + let path = request + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + Redirect::to(&format!("/login?next={}", safe_path(path))).into_response() + } +} + +// ── /auth/verify — called by Caddy forward_auth for every app request ──────── + +pub async fn verify( + State(state): State, + headers: HeaderMap, +) -> Response { + let user_id = current_user_id(&state, &headers).await; + + let uid = match user_id { + Some(u) => u, + None => { + // Not logged in — redirect to login, passing the original URL as `next`. + let host = headers + .get("x-forwarded-host") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let uri = headers + .get("x-forwarded-uri") + .and_then(|v| v.to_str().ok()) + .unwrap_or("/"); + let proto = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("https"); + let next_url = if host.is_empty() { + "/".to_string() + } else { + format!("{}://{}{}", proto, host, uri) + }; + let login_url = format!( + "https://{}//login?next={}", + state.domain_suffix, + urlencoding_simple(&next_url) + ); + return Redirect::to(&login_url).into_response(); + } + }; + + // Resolve app_id from the forwarded host header. + let host = headers + .get("x-forwarded-host") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let app_id = host + .strip_suffix(&format!(".{}", state.domain_suffix)) + .unwrap_or(host); + + if app_id.is_empty() { + return StatusCode::FORBIDDEN.into_response(); + } + + // Admin users have access to all apps. + let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?") + .bind(&uid) + .fetch_optional(&state.db) + .await + .unwrap_or(None) + .unwrap_or(0); + + if is_admin != 0 { + return StatusCode::OK.into_response(); + } + + // Regular users: check user_apps grant. + let granted: Option = + sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?") + .bind(&uid) + .bind(app_id) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + if granted.is_some() { + StatusCode::OK.into_response() + } else { + // Logged in but not granted — redirect to a "not authorised" page. + let page = format!( + "https://{}/denied?app={}", + state.domain_suffix, app_id + ); + Redirect::to(&page).into_response() + } +} + +/// Minimal percent-encoding for use in a query-string value. +fn urlencoding_simple(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0')); + out.push(char::from_digit((b & 0xf) as u32, 16).unwrap_or('0')); + } + } + } + out +} + // ── Login / Logout ────────────────────────────────────────────────────────── #[derive(Deserialize)] pub struct NextParam { - next: Option, + pub next: Option, } #[derive(Deserialize)] @@ -80,9 +225,18 @@ pub struct LoginForm { next: Option, } -pub async fn login_page(Query(params): Query) -> impl IntoResponse { +pub async fn login_page( + State(state): State, + headers: HeaderMap, + Query(params): Query, +) -> Response { + // Already logged in → redirect. + if current_user_id(&state, &headers).await.is_some() { + let next = params.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); + return Redirect::to(&next).into_response(); + } let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into()); - Html(login_html(&next, false)) + Html(login_html(&next, None)).into_response() } pub async fn handle_login( @@ -91,17 +245,45 @@ pub async fn handle_login( ) -> Response { let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); - let valid = state.admin_user.as_deref() == Some(form.username.as_str()) - && state.admin_pass.as_deref() == Some(form.password.as_str()); + // --- env-var bootstrap path (used before any DB user exists) --- + let using_bootstrap = matches!( + (&state.admin_user, &state.admin_pass), + (Some(_), Some(_)) + ) && !state.auth_enabled; - if !valid { - return Html(login_html(&next, true)).into_response(); + if using_bootstrap { + let ok = state.admin_user.as_deref() == Some(form.username.as_str()) + && state.admin_pass.as_deref() == Some(form.password.as_str()); + if !ok { + return Html(login_html(&next, Some("Invalid username or password."))).into_response(); + } + return set_session_for_bootstrap(&state, &next).await; + } + + // --- DB path --- + let user: Option = + sqlx::query_as("SELECT * FROM users WHERE username = ?") + .bind(&form.username) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + let user = match user { + Some(u) => u, + None => { + return Html(login_html(&next, Some("Invalid username or password."))).into_response(); + } + }; + + let ok = bcrypt::verify(&form.password, &user.password_hash).unwrap_or(false); + if !ok { + return Html(login_html(&next, Some("Invalid username or password."))).into_response(); } let token = uuid::Uuid::new_v4().to_string(); - state.sessions.lock().await.insert(token.clone()); + state.sessions.lock().await.insert(token.clone(), user.id); - let cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/", token); + let cookie = session_cookie_header(&token, &state.domain_suffix); let mut response = Redirect::to(&next).into_response(); response .headers_mut() @@ -109,30 +291,61 @@ pub async fn handle_login( response } +async fn set_session_for_bootstrap(state: &crate::AppState, next: &str) -> Response { + let token = uuid::Uuid::new_v4().to_string(); + // Bootstrap sessions store "bootstrap" as the user_id sentinel. + state.sessions.lock().await.insert(token.clone(), "bootstrap".to_string()); + let cookie = session_cookie_header(&token, &state.domain_suffix); + let mut response = Redirect::to(next).into_response(); + response + .headers_mut() + .insert(header::SET_COOKIE, cookie.parse().unwrap()); + response +} + pub async fn handle_logout( State(state): State, - headers: axum::http::HeaderMap, + headers: HeaderMap, ) -> Response { - if let Some(token) = session_cookie(&headers) { + if let Some(token) = get_session_cookie(&headers) { state.sessions.lock().await.remove(&token); } let mut response = Redirect::to("/login").into_response(); response.headers_mut().insert( header::SET_COOKIE, - "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" - .parse() - .unwrap(), + clear_session_cookie(&state.domain_suffix).parse().unwrap(), ); response } +/// "Access denied" page — shown when logged in but not granted the app. +pub async fn denied_page( + Query(params): Query>, +) -> impl IntoResponse { + let app = params.get("app").map(|s| s.as_str()).unwrap_or("this app"); + Html(format!( + r#"Access denied +
+

Access denied

+

You do not have access to {app}.

+

Contact your administrator to request access.

+ ← Sign in with a different account +
"# + )) +} + // ── Login page HTML ───────────────────────────────────────────────────────── -fn login_html(next: &str, error: bool) -> String { - let error_html = if error { - r#"

Invalid username or password.

"# - } else { - "" +fn login_html(next: &str, error: Option<&str>) -> String { + let error_html = match error { + Some(msg) => format!(r#"

{}

"#, msg), + None => String::new(), }; format!( r#" diff --git a/server/src/db.rs b/server/src/db.rs index ad0f7c8..b170564 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -55,5 +55,27 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> { .execute(pool) .await?; + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )"#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS user_apps ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, app_id) + )"#, + ) + .execute(pool) + .await?; + Ok(()) } diff --git a/server/src/main.rs b/server/src/main.rs index bec6e02..e930b21 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,6 +1,6 @@ use axum::{ middleware, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Router, }; use std::sync::Arc; @@ -22,11 +22,15 @@ pub struct AppState { /// Queue of deploy IDs waiting to be processed. pub build_queue: Arc>>, pub data_dir: String, - /// In-memory session tokens (cleared on restart). + /// token → user_id (in-memory; cleared on restart). pub sessions: auth::Sessions, - /// None means auth is disabled (neither env var set). + /// True when at least one user exists in the database. + pub auth_enabled: bool, + /// Bootstrap credentials (used when users table is empty). pub admin_user: Option, pub admin_pass: Option, + /// e.g. "yourdomain.com" or "localhost" — used for cookie Domain + redirect URLs. + pub domain_suffix: String, } #[tokio::main] @@ -51,14 +55,46 @@ async fn main() -> anyhow::Result<()> { let admin_user = std::env::var("HIY_ADMIN_USER").ok().filter(|s| !s.is_empty()); let admin_pass = std::env::var("HIY_ADMIN_PASS").ok().filter(|s| !s.is_empty()); + let domain_suffix = + std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into()); - if admin_user.is_none() || admin_pass.is_none() { - tracing::warn!( - "HIY_ADMIN_USER / HIY_ADMIN_PASS not set — dashboard is unprotected! \ - Set both in .env to enable authentication." - ); + // Count DB users. + let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&db) + .await + .unwrap_or(0); + + // Bootstrap: create initial admin from env vars if no users exist yet. + if user_count == 0 { + if let (Some(u), Some(p)) = (&admin_user, &admin_pass) { + let hash = bcrypt::hash(p, bcrypt::DEFAULT_COST)?; + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO users (id, username, password_hash, is_admin, created_at) \ + VALUES (?,?,?,1,?)", + ) + .bind(&id) + .bind(u.as_str()) + .bind(&hash) + .bind(&now) + .execute(&db) + .await?; + tracing::info!("Bootstrap: created admin user '{}'", u); + } else { + tracing::warn!( + "No users in database and HIY_ADMIN_USER/HIY_ADMIN_PASS not set — \ + dashboard is unprotected! Set both in .env to create the first admin." + ); + } } + let auth_enabled: bool = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&db) + .await + .unwrap_or(0i64) + > 0; + let build_queue = Arc::new(Mutex::new(VecDeque::::new())); let state = AppState { @@ -66,8 +102,10 @@ async fn main() -> anyhow::Result<()> { build_queue, data_dir, sessions: auth::new_sessions(), + auth_enabled, admin_user, admin_pass, + domain_suffix, }; // Single background worker — sequential builds to avoid saturating the Pi. @@ -76,29 +114,40 @@ async fn main() -> anyhow::Result<()> { builder::build_worker(worker_state).await; }); - // ── Protected routes (require login) ────────────────────────────────────── + // ── Protected routes (admin login required) ─────────────────────────────── let protected = Router::new() .route("/", get(routes::ui::index)) .route("/apps/:id", get(routes::ui::app_detail)) + .route("/admin/users", get(routes::ui::users_page)) .route("/api/status", get(routes::ui::status_json)) + // Apps API .route("/api/apps", get(routes::apps::list).post(routes::apps::create)) .route("/api/apps/:id", get(routes::apps::get_one) .put(routes::apps::update) .delete(routes::apps::delete)) .route("/api/apps/:id/stop", post(routes::apps::stop)) .route("/api/apps/:id/restart", post(routes::apps::restart)) + // Deploys API .route("/api/apps/:id/deploy", post(routes::deploys::trigger)) .route("/api/apps/:id/deploys", get(routes::deploys::list)) .route("/api/deploys/:id", get(routes::deploys::get_one)) .route("/api/deploys/:id/logs", get(routes::deploys::logs_sse)) + // Env vars API .route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set)) .route("/api/apps/:id/env/:key", delete(routes::envvars::remove)) + // Users API (admin only — already behind admin middleware) + .route("/api/users", get(routes::users::list).post(routes::users::create)) + .route("/api/users/:id", put(routes::users::update).delete(routes::users::delete)) + .route("/api/users/:id/apps/:app_id", post(routes::users::grant_app).delete(routes::users::revoke_app)) .route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware)); - // ── Public routes (no auth) ─────────────────────────────────────────────── + // ── Public routes ───────────────────────────────────────────────────────── let public = Router::new() - .route("/login", get(auth::login_page).post(auth::handle_login)) - .route("/logout", get(auth::handle_logout)) + .route("/login", get(auth::login_page).post(auth::handle_login)) + .route("/logout", get(auth::handle_logout)) + .route("/denied", get(auth::denied_page)) + // Called by Caddy forward_auth for every deployed-app request. + .route("/auth/verify", get(auth::verify)) // GitHub webhooks use HMAC-SHA256 — no session needed. .route("/webhook/:app_id", post(routes::webhooks::github)); diff --git a/server/src/models.rs b/server/src/models.rs index 663f97d..88f17c6 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -52,3 +52,25 @@ pub struct SetEnvVar { pub key: String, pub value: String, } + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: String, + pub username: String, + pub password_hash: String, + pub is_admin: i64, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateUser { + pub username: String, + pub password: String, + pub is_admin: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUser { + pub password: Option, + pub is_admin: Option, +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 0af62ed..b8b5172 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -2,4 +2,5 @@ pub mod apps; pub mod deploys; pub mod envvars; pub mod ui; +pub mod users; pub mod webhooks; diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 6d7f79a..3658bdb 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -215,7 +215,7 @@ pub async fn index(State(s): State) -> Result, StatusCode let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 }; let body = format!( - r#" + r#"

System

@@ -413,6 +413,7 @@ pub async fn app_detail( + users logout
@@ -602,3 +603,157 @@ pub async fn status_json( Ok(Json(serde_json::Value::Object(map))) } + +// ── Users management page ──────────────────────────────────────────────────── + +pub async fn users_page(State(state): State) -> impl IntoResponse { + // Fetch all apps for the grant dropdowns. + let apps: Vec<(String, String)> = + sqlx::query_as("SELECT id, name FROM apps ORDER BY name") + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + let app_opts: String = apps + .iter() + .map(|(id, name)| format!(r#""#)) + .collect::>() + .join(""); + + let body = format!( + r#" + +
+

Add user

+
+ + + + +
+ +
+ +
+

Users

+

Loading…

+
+ + "# + ); + + Html(page("Users", &body)) +} diff --git a/server/src/routes/users.rs b/server/src/routes/users.rs new file mode 100644 index 0000000..efe35d5 --- /dev/null +++ b/server/src/routes/users.rs @@ -0,0 +1,181 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use serde::Serialize; +use serde_json::json; + +use crate::{models::CreateUser, models::UpdateUser, AppState}; + +// ── List users ─────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct UserView { + pub id: String, + pub username: String, + pub is_admin: bool, + pub created_at: String, + pub apps: Vec, +} + +pub async fn list(State(state): State) -> impl IntoResponse { + let rows: Vec<(String, String, i64, String)> = sqlx::query_as( + "SELECT id, username, is_admin, created_at FROM users ORDER BY created_at", + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + let mut users = Vec::new(); + for (id, username, is_admin, created_at) in rows { + let apps: Vec = + sqlx::query_scalar("SELECT app_id FROM user_apps WHERE user_id = ?") + .bind(&id) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + users.push(UserView { + id, + username, + is_admin: is_admin != 0, + created_at, + apps, + }); + } + Json(users) +} + +// ── Create user ────────────────────────────────────────────────────────────── + +pub async fn create( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + if body.username.trim().is_empty() || body.password.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "username and password required"}))).into_response(); + } + + let hash = match bcrypt::hash(&body.password, bcrypt::DEFAULT_COST) { + Ok(h) => h, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to hash password"})), + ).into_response(); + } + }; + + let id = uuid::Uuid::new_v4().to_string(); + let is_admin = body.is_admin.unwrap_or(false) as i64; + let now = Utc::now().to_rfc3339(); + + let res = sqlx::query( + "INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?,?,?,?,?)", + ) + .bind(&id) + .bind(body.username.trim()) + .bind(&hash) + .bind(is_admin) + .bind(&now) + .execute(&state.db) + .await; + + match res { + Ok(_) => ( + StatusCode::CREATED, + Json(json!({"id": id, "username": body.username.trim()})), + ).into_response(), + Err(e) if e.to_string().contains("UNIQUE") => ( + StatusCode::CONFLICT, + Json(json!({"error": "username already exists"})), + ).into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "db error"})), + ).into_response(), + } +} + +// ── Update user (password / is_admin) ──────────────────────────────────────── + +pub async fn update( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + if let Some(new_pass) = &body.password { + if new_pass.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "password cannot be empty"}))).into_response(); + } + let hash = match bcrypt::hash(new_pass, bcrypt::DEFAULT_COST) { + Ok(h) => h, + Err(_) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to hash password"})), + ).into_response(); + } + }; + let _ = sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(&hash) + .bind(&id) + .execute(&state.db) + .await; + } + + if let Some(admin) = body.is_admin { + let _ = sqlx::query("UPDATE users SET is_admin = ? WHERE id = ?") + .bind(admin as i64) + .bind(&id) + .execute(&state.db) + .await; + } + + StatusCode::NO_CONTENT.into_response() +} + +// ── Delete user ─────────────────────────────────────────────────────────────── + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(&id) + .execute(&state.db) + .await + .ok(); + StatusCode::NO_CONTENT.into_response() +} + +// ── Grant / revoke app access ───────────────────────────────────────────────── + +pub async fn grant_app( + State(state): State, + Path((user_id, app_id)): Path<(String, String)>, +) -> impl IntoResponse { + let _ = sqlx::query( + "INSERT OR IGNORE INTO user_apps (user_id, app_id) VALUES (?, ?)", + ) + .bind(&user_id) + .bind(&app_id) + .execute(&state.db) + .await; + StatusCode::NO_CONTENT.into_response() +} + +pub async fn revoke_app( + State(state): State, + Path((user_id, app_id)): Path<(String, String)>, +) -> impl IntoResponse { + sqlx::query("DELETE FROM user_apps WHERE user_id = ? AND app_id = ?") + .bind(&user_id) + .bind(&app_id) + .execute(&state.db) + .await + .ok(); + StatusCode::NO_CONTENT.into_response() +}