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<token, user_id> (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=<original URL>
- 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
This commit is contained in:
Claude 2026-03-20 14:22:57 +00:00
parent 4454744cba
commit 2cdbf270f6
No known key found for this signature in database
9 changed files with 744 additions and 69 deletions

View file

@ -127,13 +127,44 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
else else
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes" ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
ROUTE_JSON=$(cat <<EOF # Route JSON uses Caddy's forward_auth pattern:
{ # 1. HIY server checks the session cookie and app-level permission at /auth/verify
"match": [{"host": ["${APP_ID}.${DOMAIN_SUFFIX}"]}], # 2. On 2xx → Caddy proxies to the app container
"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "${UPSTREAM}"}]}] # 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client
ROUTE_JSON=$(python3 -c "
import json, sys
upstream = sys.argv[1]
app_host = sys.argv[2]
hiy_server = 'server:3000'
route = {
'match': [{'host': [app_host]}],
'handle': [{
'handler': 'subroute',
'routes': [{
'handle': [{
'handler': 'reverse_proxy',
'rewrite': {'method': 'GET', 'uri': '/auth/verify'},
'headers': {
'request': {
'set': {
'X-Forwarded-Method': ['{http.request.method}'],
'X-Forwarded-Uri': ['{http.request.uri}'],
'X-Forwarded-Host': ['{http.request.host}'],
'X-Forwarded-Proto': ['{http.request.scheme}'],
}
}
},
'upstreams': [{'dial': hiy_server}],
'handle_response': [{
'match': {'status_code': [2]},
'routes': [{'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': upstream}]}]}]
}]
}]
}]
}]
} }
EOF print(json.dumps(route))
) " "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
# Upsert the route for this app. # Upsert the route for this app.
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]") ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all. # Remove existing route for the same host, rebuild list, keep dashboard as catch-all.

View file

@ -23,5 +23,6 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15" dotenvy = "0.15"
async-stream = "0.3" async-stream = "0.3"
bcrypt = "0.15"
anyhow = "1" anyhow = "1"
futures = "0.3" futures = "0.3"

View file

@ -1,51 +1,48 @@
use axum::{ use axum::{
extract::{Form, Query, Request, State}, extract::{Form, Query, Request, State},
http::header, http::{header, HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashSet; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub type Sessions = Arc<Mutex<HashSet<String>>>; /// token → user_id
pub type Sessions = Arc<Mutex<HashMap<String, String>>>;
pub fn new_sessions() -> Sessions { pub fn new_sessions() -> Sessions {
Arc::new(Mutex::new(HashSet::new())) Arc::new(Mutex::new(HashMap::new()))
} }
// ── Middleware ────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
pub async fn auth_middleware( pub fn session_cookie_header(token: &str, domain_suffix: &str) -> String {
State(state): State<crate::AppState>, let domain = if domain_suffix == "localhost" || domain_suffix.starts_with("localhost:") {
request: Request, String::new()
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
} else { } else {
let path = request format!("; Domain=.{}", domain_suffix)
.uri() };
.path_and_query() format!(
.map(|p| p.as_str()) "session={}; HttpOnly; SameSite=Lax; Path={}{}",
.unwrap_or("/"); token, "/", domain
Redirect::to(&format!("/login?next={}", safe_path(path))).into_response() )
}
} }
fn session_cookie(headers: &axum::http::HeaderMap) -> Option<String> { 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<String> {
headers headers
.get(header::COOKIE) .get(header::COOKIE)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
@ -57,6 +54,12 @@ fn session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
}) })
} }
/// Resolve the user_id for the current request, if logged in.
pub async fn current_user_id(state: &crate::AppState, headers: &HeaderMap) -> Option<String> {
let token = get_session_cookie(headers)?;
state.sessions.lock().await.get(&token).cloned()
}
/// Reject open-redirect: only allow relative paths. /// Reject open-redirect: only allow relative paths.
fn safe_path(next: &str) -> String { fn safe_path(next: &str) -> String {
if next.starts_with('/') && !next.starts_with("//") { 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<crate::AppState>,
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<crate::AppState>,
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<i64> =
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 ────────────────────────────────────────────────────────── // ── Login / Logout ──────────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct NextParam { pub struct NextParam {
next: Option<String>, pub next: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -80,9 +225,18 @@ pub struct LoginForm {
next: Option<String>, next: Option<String>,
} }
pub async fn login_page(Query(params): Query<NextParam>) -> impl IntoResponse { pub async fn login_page(
State(state): State<crate::AppState>,
headers: HeaderMap,
Query(params): Query<NextParam>,
) -> 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()); 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( pub async fn handle_login(
@ -91,17 +245,45 @@ pub async fn handle_login(
) -> Response { ) -> Response {
let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); 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()) // --- env-var bootstrap path (used before any DB user exists) ---
&& state.admin_pass.as_deref() == Some(form.password.as_str()); let using_bootstrap = matches!(
(&state.admin_user, &state.admin_pass),
(Some(_), Some(_))
) && !state.auth_enabled;
if !valid { if using_bootstrap {
return Html(login_html(&next, true)).into_response(); 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<crate::models::User> =
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(); 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(); let mut response = Redirect::to(&next).into_response();
response response
.headers_mut() .headers_mut()
@ -109,30 +291,61 @@ pub async fn handle_login(
response 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( pub async fn handle_logout(
State(state): State<crate::AppState>, State(state): State<crate::AppState>,
headers: axum::http::HeaderMap, headers: HeaderMap,
) -> Response { ) -> Response {
if let Some(token) = session_cookie(&headers) { if let Some(token) = get_session_cookie(&headers) {
state.sessions.lock().await.remove(&token); state.sessions.lock().await.remove(&token);
} }
let mut response = Redirect::to("/login").into_response(); let mut response = Redirect::to("/login").into_response();
response.headers_mut().insert( response.headers_mut().insert(
header::SET_COOKIE, header::SET_COOKIE,
"session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" clear_session_cookie(&state.domain_suffix).parse().unwrap(),
.parse()
.unwrap(),
); );
response response
} }
/// "Access denied" page — shown when logged in but not granted the app.
pub async fn denied_page(
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let app = params.get("app").map(|s| s.as_str()).unwrap_or("this app");
Html(format!(
r#"<!doctype html><html><head><meta charset=utf-8><title>Access denied</title><style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:monospace;background:#0f172a;color:#e2e8f0;
display:flex;align-items:center;justify-content:center;min-height:100vh}}
.card{{background:#1e293b;border-radius:10px;padding:32px;max-width:400px;text-align:center}}
h1{{color:#f87171;margin-bottom:16px}}p{{color:#94a3b8;margin-bottom:20px}}
a{{color:#818cf8}}</style></head><body>
<div class="card">
<h1>Access denied</h1>
<p>You do not have access to <strong>{app}</strong>.</p>
<p>Contact your administrator to request access.</p>
<a href="/login"> Sign in with a different account</a>
</div></body></html>"#
))
}
// ── Login page HTML ───────────────────────────────────────────────────────── // ── Login page HTML ─────────────────────────────────────────────────────────
fn login_html(next: &str, error: bool) -> String { fn login_html(next: &str, error: Option<&str>) -> String {
let error_html = if error { let error_html = match error {
r#"<p class="error">Invalid username or password.</p>"# Some(msg) => format!(r#"<p class="error">{}</p>"#, msg),
} else { None => String::new(),
""
}; };
format!( format!(
r#"<!doctype html><html><head><meta charset=utf-8> r#"<!doctype html><html><head><meta charset=utf-8>

View file

@ -55,5 +55,27 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
.execute(pool) .execute(pool)
.await?; .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(()) Ok(())
} }

View file

@ -1,6 +1,6 @@
use axum::{ use axum::{
middleware, middleware,
routing::{delete, get, post}, routing::{delete, get, post, put},
Router, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
@ -22,11 +22,15 @@ pub struct AppState {
/// Queue of deploy IDs waiting to be processed. /// Queue of deploy IDs waiting to be processed.
pub build_queue: Arc<Mutex<VecDeque<String>>>, pub build_queue: Arc<Mutex<VecDeque<String>>>,
pub data_dir: String, pub data_dir: String,
/// In-memory session tokens (cleared on restart). /// token → user_id (in-memory; cleared on restart).
pub sessions: auth::Sessions, 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<String>, pub admin_user: Option<String>,
pub admin_pass: Option<String>, pub admin_pass: Option<String>,
/// e.g. "yourdomain.com" or "localhost" — used for cookie Domain + redirect URLs.
pub domain_suffix: String,
} }
#[tokio::main] #[tokio::main]
@ -51,13 +55,45 @@ async fn main() -> anyhow::Result<()> {
let admin_user = std::env::var("HIY_ADMIN_USER").ok().filter(|s| !s.is_empty()); 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 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() { // 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!( tracing::warn!(
"HIY_ADMIN_USER / HIY_ADMIN_PASS not set — dashboard is unprotected! \ "No users in database and HIY_ADMIN_USER/HIY_ADMIN_PASS not set — \
Set both in .env to enable authentication." 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::<String>::new())); let build_queue = Arc::new(Mutex::new(VecDeque::<String>::new()));
@ -66,8 +102,10 @@ async fn main() -> anyhow::Result<()> {
build_queue, build_queue,
data_dir, data_dir,
sessions: auth::new_sessions(), sessions: auth::new_sessions(),
auth_enabled,
admin_user, admin_user,
admin_pass, admin_pass,
domain_suffix,
}; };
// Single background worker — sequential builds to avoid saturating the Pi. // 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; builder::build_worker(worker_state).await;
}); });
// ── Protected routes (require login) ────────────────────────────────────── // ── Protected routes (admin login required) ───────────────────────────────
let protected = Router::new() let protected = Router::new()
.route("/", get(routes::ui::index)) .route("/", get(routes::ui::index))
.route("/apps/:id", get(routes::ui::app_detail)) .route("/apps/:id", get(routes::ui::app_detail))
.route("/admin/users", get(routes::ui::users_page))
.route("/api/status", get(routes::ui::status_json)) .route("/api/status", get(routes::ui::status_json))
// Apps API
.route("/api/apps", get(routes::apps::list).post(routes::apps::create)) .route("/api/apps", get(routes::apps::list).post(routes::apps::create))
.route("/api/apps/:id", get(routes::apps::get_one) .route("/api/apps/:id", get(routes::apps::get_one)
.put(routes::apps::update) .put(routes::apps::update)
.delete(routes::apps::delete)) .delete(routes::apps::delete))
.route("/api/apps/:id/stop", post(routes::apps::stop)) .route("/api/apps/:id/stop", post(routes::apps::stop))
.route("/api/apps/:id/restart", post(routes::apps::restart)) .route("/api/apps/:id/restart", post(routes::apps::restart))
// Deploys API
.route("/api/apps/:id/deploy", post(routes::deploys::trigger)) .route("/api/apps/:id/deploy", post(routes::deploys::trigger))
.route("/api/apps/:id/deploys", get(routes::deploys::list)) .route("/api/apps/:id/deploys", get(routes::deploys::list))
.route("/api/deploys/:id", get(routes::deploys::get_one)) .route("/api/deploys/:id", get(routes::deploys::get_one))
.route("/api/deploys/:id/logs", get(routes::deploys::logs_sse)) .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", get(routes::envvars::list).post(routes::envvars::set))
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove)) .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)); .route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
// ── Public routes (no auth) ─────────────────────────────────────────────── // ── Public routes ─────────────────────────────────────────────────────────
let public = Router::new() let public = Router::new()
.route("/login", get(auth::login_page).post(auth::handle_login)) .route("/login", get(auth::login_page).post(auth::handle_login))
.route("/logout", get(auth::handle_logout)) .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. // GitHub webhooks use HMAC-SHA256 — no session needed.
.route("/webhook/:app_id", post(routes::webhooks::github)); .route("/webhook/:app_id", post(routes::webhooks::github));

View file

@ -52,3 +52,25 @@ pub struct SetEnvVar {
pub key: String, pub key: String,
pub value: 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<bool>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUser {
pub password: Option<String>,
pub is_admin: Option<bool>,
}

View file

@ -2,4 +2,5 @@ pub mod apps;
pub mod deploys; pub mod deploys;
pub mod envvars; pub mod envvars;
pub mod ui; pub mod ui;
pub mod users;
pub mod webhooks; pub mod webhooks;

View file

@ -215,7 +215,7 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 }; let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 };
let body = format!( let body = format!(
r#"<nav><h1>&#9749; HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{n} app(s)</span><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></span></nav> r#"<nav><h1>&#9749; HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{n} app(s)</span><a href="/admin/users" class="muted" style="font-size:0.82rem">users</a><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></span></nav>
<div class="card" style="padding:16px 24px"> <div class="card" style="padding:16px 24px">
<h2 style="margin-bottom:14px">System</h2> <h2 style="margin-bottom:14px">System</h2>
@ -413,6 +413,7 @@ pub async fn app_detail(
<button class="primary" onclick="deploy()">Deploy Now</button> <button class="primary" onclick="deploy()">Deploy Now</button>
<button onclick="stopApp()">Stop</button> <button onclick="stopApp()">Stop</button>
<button onclick="restartApp()">Restart</button> <button onclick="restartApp()">Restart</button>
<a href="/admin/users" class="muted" style="font-size:0.82rem;padding:0 4px">users</a>
<a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a> <a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a>
</div> </div>
</nav> </nav>
@ -602,3 +603,157 @@ pub async fn status_json(
Ok(Json(serde_json::Value::Object(map))) Ok(Json(serde_json::Value::Object(map)))
} }
// ── Users management page ────────────────────────────────────────────────────
pub async fn users_page(State(state): State<AppState>) -> 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#"<option value="{id}">{name}</option>"#))
.collect::<Vec<_>>()
.join("");
let body = format!(
r#"<nav>
<h1><a href="/" style="color:inherit">HIY</a> / Users</h1>
<span style="display:flex;gap:16px;align-items:center">
<a href="/" class="muted" style="font-size:0.82rem">dashboard</a>
<a href="/logout" class="muted" style="font-size:0.82rem">logout</a>
</span>
</nav>
<div class="card">
<h2 style="margin-bottom:16px">Add user</h2>
<div class="row" id="add-user-form">
<input type="text" id="new-username" placeholder="username" style="max-width:200px">
<input type="password" id="new-password" placeholder="password" style="max-width:200px">
<label style="display:flex;align-items:center;gap:6px;margin:0;color:#e2e8f0">
<input type="checkbox" id="new-is-admin"> Admin
</label>
<button class="primary" onclick="addUser()">Add user</button>
</div>
<p id="add-user-error" style="color:#f87171;margin-top:8px;display:none"></p>
</div>
<div class="card">
<h2 style="margin-bottom:16px">Users</h2>
<div id="users-list"><p class="muted">Loading</p></div>
</div>
<script>
const APP_OPTS = `{app_opts}`;
async function load() {{
const res = await fetch('/api/users');
const users = await res.json();
const el = document.getElementById('users-list');
if (!users.length) {{ el.innerHTML = '<p class="muted">No users yet.</p>'; return; }}
el.innerHTML = users.map(u => `
<div class="card" style="margin-bottom:12px;padding:16px">
<div class="row" style="justify-content:space-between;margin-bottom:10px">
<div>
<strong>${{u.username}}</strong>
${{u.is_admin ? '<span class="badge badge-success" style="margin-left:6px">admin</span>' : ''}}
<span class="muted" style="margin-left:10px;font-size:0.8rem">${{u.created_at.slice(0,10)}}</span>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button onclick="toggleAdmin('${{u.id}}','${{u.username}}',${{u.is_admin}})">
${{u.is_admin ? 'Remove admin' : 'Make admin'}}
</button>
<button onclick="changePassword('${{u.id}}','${{u.username}}')">Change password</button>
<button class="danger" onclick="deleteUser('${{u.id}}','${{u.username}}')">Delete</button>
</div>
</div>
<div>
<span class="muted" style="font-size:0.78rem">App access:</span>
${{u.apps.length ? u.apps.map(a => `
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
${{a}}
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px" onclick="revokeApp('${{u.id}}','${{a}}')"></a>
</span>`).join('') : '<span class="muted" style="font-size:0.82rem"> none</span>'}}
${{APP_OPTS ? `
<span style="margin-left:8px">
<select id="grant-${{u.id}}" style="background:#0f172a;color:#e2e8f0;border:1px solid #334155;
padding:3px 8px;border-radius:6px;font-family:monospace;font-size:0.82rem">
<option value="">+ grant app</option>
${{APP_OPTS}}
</select>
<button onclick="grantApp('${{u.id}}')" style="margin-left:4px">Grant</button>
</span>` : ''}}
</div>
</div>`).join('');
}}
async function addUser() {{
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const is_admin = document.getElementById('new-is-admin').checked;
const err = document.getElementById('add-user-error');
if (!username || !password) {{ err.textContent = 'Username and password required.'; err.style.display=''; return; }}
const res = await fetch('/api/users', {{
method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{username, password, is_admin}})
}});
if (res.ok) {{
document.getElementById('new-username').value='';
document.getElementById('new-password').value='';
document.getElementById('new-is-admin').checked=false;
err.style.display='none';
load();
}} else {{
const d = await res.json();
err.textContent = d.error || 'Error creating user.';
err.style.display = '';
}}
}}
async function deleteUser(id, name) {{
if (!confirm('Delete user ' + name + '?')) return;
await fetch('/api/users/' + id, {{method:'DELETE'}});
load();
}}
async function toggleAdmin(id, name, currentlyAdmin) {{
await fetch('/api/users/' + id, {{
method:'PUT', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{is_admin: !currentlyAdmin}})
}});
load();
}}
async function changePassword(id, name) {{
const pw = prompt('New password for ' + name + ':');
if (!pw) return;
await fetch('/api/users/' + id, {{
method:'PUT', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{password: pw}})
}});
}}
async function grantApp(userId) {{
const sel = document.getElementById('grant-' + userId);
const appId = sel.value;
if (!appId) return;
await fetch('/api/users/' + userId + '/apps/' + appId, {{method:'POST'}});
sel.value = '';
load();
}}
async function revokeApp(userId, appId) {{
await fetch('/api/users/' + userId + '/apps/' + appId, {{method:'DELETE'}});
load();
}}
load();
</script>"#
);
Html(page("Users", &body))
}

181
server/src/routes/users.rs Normal file
View file

@ -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<String>,
}
pub async fn list(State(state): State<AppState>) -> 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<String> =
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<AppState>,
Json(body): Json<CreateUser>,
) -> 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<AppState>,
Path(id): Path<String>,
Json(body): Json<UpdateUser>,
) -> 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<AppState>,
Path(id): Path<String>,
) -> 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<AppState>,
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<AppState>,
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()
}