From 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Thu, 17 Sep 2026 14:32:37 +0800 Subject: chore: establish Strategy Lab source baseline (development, not release) --- server/src/admin.rs | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 server/src/admin.rs (limited to 'server/src/admin.rs') diff --git a/server/src/admin.rs b/server/src/admin.rs new file mode 100644 index 0000000..d6a861e --- /dev/null +++ b/server/src/admin.rs @@ -0,0 +1,387 @@ +use serde_json::json; +use axum::Json; + +use crate::auth::{audit, hash_password, make_admin_token, AuthUser}; +use crate::error::{AppError, AppResult}; +use crate::state::Cx; +use crate::util::now_iso; + +async fn assert_admin(_cx: &Cx, auth: &AuthUser) -> AppResult<()> { + if auth.role != "admin" { + return Err(AppError::forbidden("admin only")); + } + Ok(()) +} + +fn user_json(id: &str, email: &str, name: &str, role: &str, active: i64, ai: i64, limit: i64, created: String) -> serde_json::Value { + json!({ + "id": id, "email": email, "name": name, "role": role, + "active": active != 0, "ai_enabled": ai != 0, + "daily_run_limit": limit, "created_at": created, + }) +} + +pub async fn users(cx: Cx, auth: AuthUser) -> AppResult> { + assert_admin(&cx, &auth).await?; + let items: Vec = cx.with_db(|db| { + let mut st = db.prepare("SELECT id,email,name,role,active,ai_enabled,daily_run_limit,created_at FROM users ORDER BY created_at ASC")?; + let v: Vec = st.query_map([], |r| Ok(user_json( + &r.get::<_, String>(0)?, &r.get::<_, String>(1)?, &r.get::<_, String>(2)?, &r.get::<_, String>(3)?, + r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?, r.get::<_, String>(7)?, + )))?.collect::>()?; + Ok::<_, AppError>(v) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +#[derive(serde::Deserialize)] +pub struct AdminUserPatch { + pub active: Option, + pub role: Option, + pub daily_run_limit: Option, + pub ai_enabled: Option, +} + +pub async fn patch_user(cx: Cx, auth: AuthUser, axum::extract::Path(target): axum::extract::Path, body: Option>) -> AppResult> { + assert_admin(&cx, &auth).await?; + let axum::Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if let Some(role) = &r.role { + if !matches!(role.as_str(), "admin" | "member") { + return Err(AppError::bad("validation", "role must be admin|member")); + } + } + if let Some(n) = r.daily_run_limit { + if !(1..=2000).contains(&n) { + return Err(AppError::bad("validation", "daily_run_limit must be between 1 and 2000")); + } + } + cx.with_db(|db| -> AppResult<()> { + let tx = db.transaction()?; + let (trole, tactive): (String, i64) = tx.query_row( + "SELECT role, active FROM users WHERE id=?1", [&target], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|_| AppError::not_found("user not found"))?; + let demoting = r.role.as_ref().map(|new| trole == "admin" && new != "admin").unwrap_or(false); + let disabling = r.active == Some(false); + // Last active admin protection, enforced atomically with the update. + if (demoting || disabling) && trole == "admin" && tactive != 0 { + let active_admins: i64 = tx.query_row("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1", [], |row| row.get(0))?; + if active_admins <= 1 { + return Err(AppError::conflict("last_admin", "cannot demote or disable the last active admin")); + } + } + tx.execute( + "UPDATE users SET ai_enabled=COALESCE(?1,ai_enabled), daily_run_limit=COALESCE(?2,daily_run_limit), role=COALESCE(?3,role), active=COALESCE(?4,active) WHERE id=?5", + rusqlite::params![ + r.ai_enabled.map(|b| b as i64), + r.daily_run_limit, + &r.role, + r.active.map(|b| b as i64), + &target, + ])?; + // Disabling revokes every session and freezes that user's live runs, + // inside the same transaction as the account state flip. + if disabling { + tx.execute("DELETE FROM sessions WHERE user_id=?1", [&target]).ok(); + tx.execute( + "UPDATE runs SET status='cancelled', error='account disabled', finished_at=?1 WHERE user_id=?2 AND status IN ('queued','running')", + rusqlite::params![now_iso(), &target], + ).ok(); + } + tx.commit()?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "admin_user_update", &target, "ok").await; + let v = cx.with_db(|db| { + db.query_row("SELECT id,email,name,role,active,ai_enabled,daily_run_limit,created_at FROM users WHERE id=?1", [&target], |r| + Ok(user_json( + &r.get::<_, String>(0)?, &r.get::<_, String>(1)?, &r.get::<_, String>(2)?, &r.get::<_, String>(3)?, + r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?, r.get::<_, String>(7)?, + ))).map_err(|_| AppError::not_found("user not found")) + }).await?; + Ok(Json(v)) +} + +#[derive(serde::Deserialize)] +pub struct InvitationReq { + pub email: Option, + pub role: Option, + pub expires_hours: Option, +} + +pub async fn create_invitation(cx: Cx, auth: AuthUser, body: Option>) -> AppResult<(axum::http::StatusCode, Json)> { + assert_admin(&cx, &auth).await?; + let axum::Json(r) = body.unwrap_or(axum::Json(InvitationReq { email: None, role: None, expires_hours: None })); + let role = r.role.unwrap_or_else(|| "member".to_string()); + // POC: invitations can only mint members; admins are bootstrapped offline. + // The invitation's role is stored in DB and registration ignores any + // client-supplied role input, so no escalation path exists. + if role != "member" { + return Err(AppError::bad("validation", "POC invitations can only create member role")); + } + let hours = r.expires_hours.unwrap_or(168); + if !(1..=336).contains(&hours) { + return Err(AppError::bad("validation", "expires_hours must be 1-336")); + } + let (token, expires) = make_admin_token(&cx, "invitations", None, hours, r.email.as_deref()).await?; + audit(&cx, Some(&auth.id), "invitation_issued", r.email.as_deref().unwrap_or("open-invite"), "ok").await; + Ok((axum::http::StatusCode::CREATED, Json(json!({ "token": token, "expires_at": expires })))) +} + +/// Sanitized invitations list: no token hashes, no secrets. +pub async fn list_invitations(cx: Cx, auth: AuthUser) -> AppResult> { + assert_admin(&cx, &auth).await?; + let items: Vec = cx.with_db(|db| { + let now = now_iso(); + let mut st = db.prepare("SELECT id,email,role,expires_at,used_by,created_at FROM invitations WHERE expires_at > ?1 ORDER BY created_at DESC")?; + let v = st.query_map([&now], |r| Ok(json!({ + "id": r.get::<_, String>(0)?, + "email": r.get::<_, Option>(1)?, + "role": r.get::<_, String>(2)?, + "expires_at": r.get::<_, String>(3)?, + "used_by": r.get::<_, Option>(4)?, + "created_at": r.get::<_, String>(5)?, + })))?.collect::, _>>()?; + Ok::<_, AppError>(v) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn delete_invitation(cx: Cx, auth: AuthUser, axum::extract::Path(id): axum::extract::Path) -> AppResult> { + assert_admin(&cx, &auth).await?; + cx.with_db(|db| -> AppResult<()> { + if db.execute("DELETE FROM invitations WHERE id=?1", [&id])? == 0 { + return Err(AppError::not_found("invitation not found")); + } + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "invitation_revoke", &id, "ok").await; + Ok(Json(json!({"ok": true}))) +} + +/// Admin-issued password reset token, hashed in DB, short-lived single-use. +/// The raw token is returned once for display; no fake email delivery. +pub async fn create_reset(cx: Cx, auth: AuthUser, axum::extract::Path(target): axum::extract::Path, _body: Option>) -> AppResult<(axum::http::StatusCode, Json)> { + assert_admin(&cx, &auth).await?; + cx.with_db(|db| { + db.query_row("SELECT 1 FROM users WHERE id=?1", [&target], |row| row.get::<_, i64>(0)) + .map_err(|_| AppError::not_found("user not found"))?; + Ok::<_, AppError>(()) + }).await?; + let hours = 2; + let (token, expires) = make_admin_token(&cx, "password_resets", Some(target.as_str()), hours, None).await?; + audit(&cx, Some(&auth.id), "admin_password_reset_issued", &target, "ok").await; + Ok((axum::http::StatusCode::CREATED, Json(json!({"reset_token": token, "expires_at": expires})))) +} + +/// Sanitized security audit listing: actor/action/target/time/status only. +/// Sensitive material never reaches this table (see auth::audit) and stored +/// targets are rendered trimmed, never with password/key/code content. +pub async fn audit_list(cx: Cx, auth: AuthUser) -> AppResult> { + assert_admin(&cx, &auth).await?; + let items: Vec = cx.with_db(|db| { + let mut st = db.prepare("SELECT ts,actor_id,action,target,status FROM audit ORDER BY seq DESC LIMIT 500")?; + let v = st.query_map([], |r| { + let ts: String = r.get(0)?; + let actor: Option = r.get(1)?; + let action: String = r.get(2)?; + let target: Option = r.get(3)?; + let status: String = r.get(4)?; + let items_json = json!({ + "ts": ts, "actor": actor, "action": action, + "target": target.unwrap_or_default(), "status": status, + }); + Ok(items_json) + })?.collect::, _>>()?; + Ok::<_, AppError>(v) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +/// Bootstrap the first admin from env. Idempotent: only inserts when no +/// admin account exists yet. +pub async fn bootstrap_admin(cx: &Cx) -> AppResult<()> { + let cfg = &cx.cfg; + let Some(email) = cfg.bootstrap_admin_email.clone() else { return Ok(()); }; + let Some(password) = cfg.bootstrap_admin_password.clone() else { return Ok(()); }; + let email = email.trim().to_lowercase(); + if email.is_empty() || !email.contains('@') || password.len() < 8 { + return Ok(()); + } + let exists: i64 = cx.with_db(|db| { + db.query_row("SELECT COUNT(*) FROM users WHERE role='admin'", [], |r| r.get(0)).unwrap_or(0) + }).await; + if exists > 0 { return Ok(()); } + let hash = hash_password(&password)?; + let id = crate::util::new_id(); + cx.with_db(|db| { + db.execute("INSERT OR IGNORE INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES (?1,?2,?3,'Administrator','admin',1,1,100,?4)", + rusqlite::params![&id, &email, &hash, now_iso()]).ok(); + }).await; + audit(cx, Some(&id), "bootstrap_admin", &id, "ok").await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AppState; + use axum::extract::{Path as P, State}; + use std::sync::Arc; + + fn handle(s: &Arc) -> Cx { State(s.clone()) } + + fn admin_auth() -> AuthUser { + AuthUser { id: "admin-x".into(), email: "admin@example.invalid".into(), role: "admin".into(), ai_enabled: true, daily_run_limit: 100, session_id: "sess-admin".into() } + } + + fn member_auth() -> AuthUser { + AuthUser { id: "member-x".into(), email: "member@example.invalid".into(), role: "member".into(), ai_enabled: false, daily_run_limit: 10, session_id: "sess-member".into() } + } + + fn test_state() -> Arc { + let conn = rusqlite::Connection::open_in_memory().expect("in-memory db"); + crate::db::init_db(&conn).expect("schema"); + let cfg = crate::config::Config { + bind_addr: "127.0.0.1:0".into(), + canonical_origin: String::new(), + secure_cookies: false, + db_path: ":memory:".into(), + frontend_dir: "frontend/dist".into(), + data_dir: std::env::temp_dir().to_string_lossy().into_owned(), + worker_image: "test".into(), + fetch_timeout_secs: 1, + backtest_timeout_secs: 1, + run_concurrency: 1, + fetch_concurrency: 1, + session_hours: 24, + bootstrap_admin_email: None, + bootstrap_admin_password: None, + ai_base_url: "http://127.0.0.1:9".into(), + ai_model: "test-model".into(), + ai_daily_request_cap: 1, + ai_input_token_cap: 1, + ai_output_token_cap: 1, + ai_enabled_poc: false, + default_run_limit_per_day: 10, + version: "test".into(), + }; + Arc::new(AppState { + cfg, + db: tokio::sync::Mutex::new(conn), + run_sem: Arc::new(tokio::sync::Semaphore::new(1)), + fetch_sem: Arc::new(tokio::sync::Semaphore::new(1)), + }) + } + + /// Insert a user directly and return its id. + async fn seed_user(cx: &Cx, email: &str, role: &str, password: &str) -> String { + let hash = crate::auth::hash_password(password).unwrap(); + let uid = crate::util::new_id(); + cx.with_db(|db| { + db.execute( + "INSERT INTO users (id,email,password_hash,name,role,active,ai_enabled,daily_run_limit,created_at) VALUES (?1,?2,?3,?4,?5,1,0,10,?6)", + rusqlite::params![&uid, email, &hash, "Test User", role, now_iso()]).unwrap(); + }).await; + uid + } + + #[tokio::test] + async fn member_cannot_list_users_or_audit() { + let s = test_state(); + let u = users(handle(&s), member_auth()).await; + assert_eq!(u.err().map(|e| e.status).unwrap_or_default(), axum::http::StatusCode::FORBIDDEN); + let a = audit_list(handle(&s), member_auth()).await; + assert_eq!(a.err().map(|e| e.status).unwrap_or_default(), axum::http::StatusCode::FORBIDDEN); + let inv = create_invitation(handle(&s), member_auth(), None).await; + assert_eq!(inv.err().map(|e| e.status).unwrap_or_default(), axum::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn last_active_admin_cannot_be_disabled_or_demoted() { + let s = test_state(); + let aid = seed_user(&handle(&s), "last-admin@example.invalid", "admin", "an-admin-passphrase").await; + let res = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: Some(false), role: None, daily_run_limit: None, ai_enabled: None }))).await; + assert_eq!(res.err().map(|e| e.code).unwrap_or_default(), "last_admin", "disabling the last admin must be blocked"); + let res = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: None, role: Some("member".into()), daily_run_limit: None, ai_enabled: None }))).await; + assert_eq!(res.err().map(|e| e.code).unwrap_or_default(), "last_admin", "demoting the last admin must be blocked"); + // With a second active admin, disabling becomes legal. + let _ = seed_user(&handle(&s), "second-admin@example.invalid", "admin", "another-passphrase-2").await; + let ok = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: Some(false), role: None, daily_run_limit: None, ai_enabled: None }))).await; + assert!(ok.is_ok()); + let out = ok.unwrap().0; + assert_eq!(out["active"], json!(false)); + } + + #[tokio::test] + async fn disabling_user_revokes_sessions_and_cancels_runs() { + let s = test_state(); + let aid = seed_user(&handle(&s), "freeze@example.invalid", "member", "a-member-passphrase").await; + handle(&s).with_db(|db| { + db.execute("INSERT INTO sessions (id,user_id,created_at,expires_at) VALUES ('sess-1',?1,?2,?3)", + rusqlite::params![&aid, now_iso(), crate::util::plus_hours(1)]).unwrap(); + db.execute( + "INSERT INTO projects (id,user_id,name,draft_code,draft_generation,created_at,updated_at) VALUES ('pid',?1,'p','',0,?2,?2)", + rusqlite::params![&aid, now_iso()]).unwrap(); + db.execute( + "INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES ('vid','pid','code','h','m','manual',?1)", + [now_iso()]).unwrap(); + db.execute( + "INSERT INTO datasets (id,user_id,name,request,status,created_at,updated_at) VALUES ('did',?1,'ds','{}','ready',?2,?2)", + rusqlite::params![&aid, now_iso()]).unwrap(); + db.execute( + "INSERT INTO runs (id,user_id,project_id,version_id,dataset_id,status,config,created_at) VALUES ('rid',?1,'pid','vid','did','running','{}',?2)", + rusqlite::params![&aid, now_iso()]).unwrap(); + }).await; + let ok = patch_user(handle(&s), admin_auth(), P(aid.clone()), Some(axum::Json(AdminUserPatch { active: Some(false), role: None, daily_run_limit: None, ai_enabled: None }))).await; + assert!(ok.is_ok(), "member can be disabled"); + let blocked: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM runs WHERE id='rid' AND status='cancelled'", [], |r| r.get(0)).unwrap()).await; + assert_eq!(blocked, 1, "live runs must be cancelled"); + let gone: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id=?1", [&aid], |r| r.get(0)).unwrap()).await; + assert_eq!(gone, 0, "sessions must be revoked"); + } + + #[tokio::test] + async fn invitations_only_mint_members_and_are_hashed() { + let s = test_state(); + let res = create_invitation(handle(&s), admin_auth(), None).await.unwrap(); + assert_eq!(res.0, axum::http::StatusCode::CREATED); + let token = res.1.0["token"].as_str().unwrap().to_string(); + assert!(!token.contains("password")); + // The DB must hold only the sha256 of the token, never the raw token. + let hash = crate::util::sha256_hex(token.as_bytes()); + let hashed_rows: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM invitations WHERE token_hash=?1", [&hash], |r| r.get(0)).unwrap()).await; + assert_eq!(hashed_rows, 1); + let raw_rows: i64 = handle(&s).with_db(|db| db.query_row( + "SELECT COUNT(*) FROM invitations WHERE token_hash=?1", [&token], |r| r.get(0)).unwrap()).await; + assert_eq!(raw_rows, 0, "raw tokens must never be stored"); + // Admin roles via invitation are rejected. + let offered = create_invitation(handle(&s), admin_auth(), Some(axum::Json(InvitationReq { email: None, role: Some("admin".into()), expires_hours: None }))).await; + assert_eq!(offered.err().map(|e| e.code).unwrap_or_default(), "validation"); + // Sanitized listing contains no token material. + let list = list_invitations(handle(&s), admin_auth()).await.unwrap().0; + let raw = serde_json::to_string(&list).unwrap(); + assert!(!raw.contains(&hash), "listing must not leak token hashes"); + } + + #[tokio::test] + async fn bootstrap_admin_is_idempotent_and_hashes_password() { + let mut cfg_state = test_state(); + { + let cfg = &mut Arc::get_mut(&mut cfg_state).unwrap().cfg; + cfg.bootstrap_admin_email = Some("BOOT@example.invalid ".into()); + cfg.bootstrap_admin_password = Some("boot-admin-passphrase".into()); + } + let cx = handle(&cfg_state); + bootstrap_admin(&cx).await.unwrap(); + bootstrap_admin(&cx).await.unwrap(); + let count: i64 = cx.with_db(|db| db.query_row( + "SELECT COUNT(*) FROM users WHERE role='admin'", [], |r| r.get(0)).unwrap()).await; + assert_eq!(count, 1, "bootstrap must not duplicate admins"); + let hash: String = cx.with_db(|db| db.query_row( + "SELECT password_hash FROM users WHERE role='admin'", [], |r| r.get(0)).unwrap()).await; + assert!(hash.starts_with("$argon2"), "bootstrap password must be Argon2 hashed"); + } +} -- cgit v1.2.3