diff --git a/Cargo.lock b/Cargo.lock index fd4e4fe..8bb8803 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -206,6 +218,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1233,6 +1254,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pear" version = "0.2.9" @@ -1337,15 +1369,20 @@ dependencies = [ name = "picloud-manager-core" version = "0.5.1" dependencies = [ + "argon2", "async-trait", "axum", + "base64", "chrono", "picloud-orchestrator-core", "picloud-shared", + "rand 0.8.6", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", + "tokio", "tracing", "url", "uuid", diff --git a/Cargo.toml b/Cargo.toml index b4c092a..13fe214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus url = "2" urlencoding = "2" +# Auth (admin users + sessions) +argon2 = "0.5" +rand = { version = "0.8", features = ["getrandom"] } +sha2 = "0.10" +base64 = "0.22" + [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml index 0ff13fb..4b6f683 100644 --- a/crates/manager-core/Cargo.toml +++ b/crates/manager-core/Cargo.toml @@ -22,3 +22,11 @@ uuid.workspace = true chrono.workspace = true sqlx.workspace = true url.workspace = true + +argon2.workspace = true +rand.workspace = true +sha2.workspace = true +base64.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/manager-core/migrations/0004_admin_auth.sql b/crates/manager-core/migrations/0004_admin_auth.sql new file mode 100644 index 0000000..df6ea98 --- /dev/null +++ b/crates/manager-core/migrations/0004_admin_auth.sql @@ -0,0 +1,33 @@ +-- Phase 3a admin auth — see blueprint §11.4. +-- +-- Per-user platform-operator accounts (distinct from the v1.1+ `users` +-- table, which is for script-end users). Every authenticated admin is a +-- full admin in this cut; role/permission tables will be added later +-- without touching this schema. +-- +-- `admin_sessions.token_hash` stores SHA-256 of the raw token; the raw +-- value only ever exists in the login response, the HttpOnly cookie, and +-- bearer-token requests. Cascade on user delete kills the user's sessions +-- automatically — which is also why deactivating a user can simply wipe +-- their rows instead of marking each session expired. + +CREATE TABLE admin_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE admin_sessions ( + token_hash TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id); +CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at); diff --git a/crates/manager-core/src/admin_session_repo.rs b/crates/manager-core/src/admin_session_repo.rs new file mode 100644 index 0000000..71bd96f --- /dev/null +++ b/crates/manager-core/src/admin_session_repo.rs @@ -0,0 +1,152 @@ +//! CRUD over the `admin_sessions` table. +//! +//! The token never appears in this module — only its SHA-256 hash. The +//! raw value lives in `auth::GeneratedToken` long enough to hit the +//! cookie and the JSON response, then is forgotten. Lookups also filter +//! expired rows at query time so a delayed prune sweep can never extend +//! a session's life. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::AdminUserId; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum AdminSessionRepositoryError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +/// Result of a session lookup. Includes the user id (for auth context) +/// and the existing `expires_at` so the middleware can decide whether +/// the sliding window bump is worth a write. +#[derive(Debug, Clone)] +pub struct AdminSessionLookup { + pub user_id: AdminUserId, + pub expires_at: DateTime, +} + +#[async_trait] +pub trait AdminSessionRepository: Send + Sync { + async fn create( + &self, + user_id: AdminUserId, + token_hash: &str, + expires_at: DateTime, + ) -> Result<(), AdminSessionRepositoryError>; + /// Look up a session by token hash. Returns `None` for missing or + /// already-expired rows (the query filters them). + async fn lookup( + &self, + token_hash: &str, + ) -> Result, AdminSessionRepositoryError>; + /// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at` + /// to the supplied value. + async fn touch( + &self, + token_hash: &str, + new_expires_at: DateTime, + ) -> Result<(), AdminSessionRepositoryError>; + async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>; + /// Delete every session belonging to a user. Used when the user is + /// deactivated or has their password reset out-of-band — both + /// invalidate all current logins for that account. + async fn delete_for_user( + &self, + user_id: AdminUserId, + ) -> Result; + /// Sweep expired rows. The auth middleware filters expired rows on + /// lookup, so this is just bounded-growth hygiene, not correctness. + async fn prune_expired(&self) -> Result; +} + +pub struct PostgresAdminSessionRepository { + pool: PgPool, +} + +impl PostgresAdminSessionRepository { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AdminSessionRepository for PostgresAdminSessionRepository { + async fn create( + &self, + user_id: AdminUserId, + token_hash: &str, + expires_at: DateTime, + ) -> Result<(), AdminSessionRepositoryError> { + sqlx::query( + "INSERT INTO admin_sessions (token_hash, user_id, expires_at) \ + VALUES ($1, $2, $3)", + ) + .bind(token_hash) + .bind(user_id.into_inner()) + .bind(expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn lookup( + &self, + token_hash: &str, + ) -> Result, AdminSessionRepositoryError> { + let row: Option<(uuid::Uuid, DateTime)> = sqlx::query_as( + "SELECT user_id, expires_at FROM admin_sessions \ + WHERE token_hash = $1 AND expires_at > NOW()", + ) + .bind(token_hash) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(uid, exp)| AdminSessionLookup { + user_id: uid.into(), + expires_at: exp, + })) + } + + async fn touch( + &self, + token_hash: &str, + new_expires_at: DateTime, + ) -> Result<(), AdminSessionRepositoryError> { + sqlx::query( + "UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \ + WHERE token_hash = $1", + ) + .bind(token_hash) + .bind(new_expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> { + sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1") + .bind(token_hash) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete_for_user( + &self, + user_id: AdminUserId, + ) -> Result { + let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1") + .bind(user_id.into_inner()) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn prune_expired(&self) -> Result { + let res = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= NOW()") + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/crates/manager-core/src/admin_user_repo.rs b/crates/manager-core/src/admin_user_repo.rs new file mode 100644 index 0000000..08c20ea --- /dev/null +++ b/crates/manager-core/src/admin_user_repo.rs @@ -0,0 +1,322 @@ +//! CRUD over the `admin_users` table. +//! +//! Password hashes go in and come out as opaque strings — this module +//! never inspects or computes them; that's `auth.rs`'s job. The "must +//! keep at least one active admin" guard is implemented as a separate +//! count query the API layer composes around `set_active` / `delete`. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::AdminUserId; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum AdminUserRepositoryError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), + + #[error("not found: {0}")] + NotFound(AdminUserId), + + #[error("username already taken: {0}")] + DuplicateUsername(String), +} + +/// Row returned to handlers and bootstrap. Never includes the password +/// hash by accident — that lives in `AdminUserCredentials` (separate +/// fetch from `get_credentials_by_username`). +#[derive(Debug, Clone)] +pub struct AdminUserRow { + pub id: AdminUserId, + pub username: String, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login_at: Option>, +} + +/// Credentials fetched for the login path only. Splitting the hash off +/// from the public row makes it obvious in handler code which calls +/// touch a secret. +#[derive(Debug, Clone)] +pub struct AdminUserCredentials { + pub id: AdminUserId, + pub username: String, + pub password_hash: String, + pub is_active: bool, +} + +#[async_trait] +pub trait AdminUserRepository: Send + Sync { + async fn get(&self, id: AdminUserId) -> Result, AdminUserRepositoryError>; + async fn get_by_username( + &self, + username: &str, + ) -> Result, AdminUserRepositoryError>; + async fn get_credentials_by_username( + &self, + username: &str, + ) -> Result, AdminUserRepositoryError>; + async fn list(&self) -> Result, AdminUserRepositoryError>; + async fn create( + &self, + username: &str, + password_hash: &str, + ) -> Result; + async fn update_username( + &self, + id: AdminUserId, + username: &str, + ) -> Result; + async fn update_password_hash( + &self, + id: AdminUserId, + password_hash: &str, + ) -> Result; + async fn set_active( + &self, + id: AdminUserId, + is_active: bool, + ) -> Result; + async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>; + async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError>; + /// Count of `is_active = true` rows. Used at bootstrap to decide + /// whether to seed the first admin. + async fn count_active(&self) -> Result; + /// Count of `is_active = true` rows excluding the given id. Used by + /// last-admin protection: "would deactivating / deleting this user + /// leave zero active admins?" + async fn count_active_excluding( + &self, + id: AdminUserId, + ) -> Result; +} + +pub struct PostgresAdminUserRepository { + pool: PgPool, +} + +impl PostgresAdminUserRepository { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AdminUserRepository for PostgresAdminUserRepository { + async fn get(&self, id: AdminUserId) -> Result, AdminUserRepositoryError> { + let row = sqlx::query_as::<_, AdminUserRecord>( + "SELECT id, username, is_active, created_at, updated_at, last_login_at \ + FROM admin_users WHERE id = $1", + ) + .bind(id.into_inner()) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(Into::into)) + } + + async fn get_by_username( + &self, + username: &str, + ) -> Result, AdminUserRepositoryError> { + let row = sqlx::query_as::<_, AdminUserRecord>( + "SELECT id, username, is_active, created_at, updated_at, last_login_at \ + FROM admin_users WHERE username = $1", + ) + .bind(username) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(Into::into)) + } + + async fn get_credentials_by_username( + &self, + username: &str, + ) -> Result, AdminUserRepositoryError> { + let row = sqlx::query_as::<_, AdminCredsRecord>( + "SELECT id, username, password_hash, is_active \ + FROM admin_users WHERE username = $1", + ) + .bind(username) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(Into::into)) + } + + async fn list(&self) -> Result, AdminUserRepositoryError> { + let rows = sqlx::query_as::<_, AdminUserRecord>( + "SELECT id, username, is_active, created_at, updated_at, last_login_at \ + FROM admin_users ORDER BY username", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(Into::into).collect()) + } + + async fn create( + &self, + username: &str, + password_hash: &str, + ) -> Result { + let res = sqlx::query_as::<_, AdminUserRecord>( + "INSERT INTO admin_users (username, password_hash) \ + VALUES ($1, $2) \ + RETURNING id, username, is_active, created_at, updated_at, last_login_at", + ) + .bind(username) + .bind(password_hash) + .fetch_one(&self.pool) + .await; + + match res { + Ok(row) => Ok(row.into()), + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err( + AdminUserRepositoryError::DuplicateUsername(username.to_string()), + ), + Err(e) => Err(e.into()), + } + } + + async fn update_username( + &self, + id: AdminUserId, + username: &str, + ) -> Result { + let res = sqlx::query_as::<_, AdminUserRecord>( + "UPDATE admin_users SET username = $2, updated_at = NOW() \ + WHERE id = $1 \ + RETURNING id, username, is_active, created_at, updated_at, last_login_at", + ) + .bind(id.into_inner()) + .bind(username) + .fetch_optional(&self.pool) + .await; + + match res { + Ok(Some(row)) => Ok(row.into()), + Ok(None) => Err(AdminUserRepositoryError::NotFound(id)), + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err( + AdminUserRepositoryError::DuplicateUsername(username.to_string()), + ), + Err(e) => Err(e.into()), + } + } + + async fn update_password_hash( + &self, + id: AdminUserId, + password_hash: &str, + ) -> Result { + let row = sqlx::query_as::<_, AdminUserRecord>( + "UPDATE admin_users SET password_hash = $2, updated_at = NOW() \ + WHERE id = $1 \ + RETURNING id, username, is_active, created_at, updated_at, last_login_at", + ) + .bind(id.into_inner()) + .bind(password_hash) + .fetch_optional(&self.pool) + .await?; + row.map(Into::into) + .ok_or(AdminUserRepositoryError::NotFound(id)) + } + + async fn set_active( + &self, + id: AdminUserId, + is_active: bool, + ) -> Result { + let row = sqlx::query_as::<_, AdminUserRecord>( + "UPDATE admin_users SET is_active = $2, updated_at = NOW() \ + WHERE id = $1 \ + RETURNING id, username, is_active, created_at, updated_at, last_login_at", + ) + .bind(id.into_inner()) + .bind(is_active) + .fetch_optional(&self.pool) + .await?; + row.map(Into::into) + .ok_or(AdminUserRepositoryError::NotFound(id)) + } + + async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> { + let res = sqlx::query("DELETE FROM admin_users WHERE id = $1") + .bind(id.into_inner()) + .execute(&self.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AdminUserRepositoryError::NotFound(id)); + } + Ok(()) + } + + async fn touch_last_login(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> { + sqlx::query("UPDATE admin_users SET last_login_at = NOW() WHERE id = $1") + .bind(id.into_inner()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_active(&self) -> Result { + let (count,): (i64,) = + sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active") + .fetch_one(&self.pool) + .await?; + Ok(count) + } + + async fn count_active_excluding( + &self, + id: AdminUserId, + ) -> Result { + let (count,): (i64,) = + sqlx::query_as("SELECT COUNT(*)::BIGINT FROM admin_users WHERE is_active AND id <> $1") + .bind(id.into_inner()) + .fetch_one(&self.pool) + .await?; + Ok(count) + } +} + +#[derive(sqlx::FromRow)] +struct AdminUserRecord { + id: uuid::Uuid, + username: String, + is_active: bool, + created_at: DateTime, + updated_at: DateTime, + last_login_at: Option>, +} + +impl From for AdminUserRow { + fn from(r: AdminUserRecord) -> Self { + Self { + id: r.id.into(), + username: r.username, + is_active: r.is_active, + created_at: r.created_at, + updated_at: r.updated_at, + last_login_at: r.last_login_at, + } + } +} + +#[derive(sqlx::FromRow)] +struct AdminCredsRecord { + id: uuid::Uuid, + username: String, + password_hash: String, + is_active: bool, +} + +impl From for AdminUserCredentials { + fn from(r: AdminCredsRecord) -> Self { + Self { + id: r.id.into(), + username: r.username, + password_hash: r.password_hash, + is_active: r.is_active, + } + } +} diff --git a/crates/manager-core/src/admin_users_api.rs b/crates/manager-core/src/admin_users_api.rs new file mode 100644 index 0000000..1194af4 --- /dev/null +++ b/crates/manager-core/src/admin_users_api.rs @@ -0,0 +1,320 @@ +//! `/api/v1/admin/admins/*` — admin user CRUD. Guarded by +//! `require_admin`; every authenticated admin can call all of these. +//! Role/permission walls land later (see blueprint §11.4 — no +//! privilege levels in this cut). +//! +//! "Last active admin" protection lives at the service layer (not just +//! the DB) so it can produce a clean 422 with a human-readable message +//! rather than a SQL constraint violation. Deactivating a user also +//! wipes their sessions; deleting cascades through the FK. + +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::get; +use axum::Router; +use chrono::{DateTime, Utc}; +use picloud_shared::AdminUserId; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::admin_session_repo::AdminSessionRepository; +use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow}; +use crate::auth::hash_password; + +/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is +/// a strict ASCII subset so the lookup column stays predictable, and +/// password has a minimum length but no complexity rules (complexity +/// rules push users to predictable patterns). +const USERNAME_MIN: usize = 2; +const USERNAME_MAX: usize = 32; +const PASSWORD_MIN: usize = 8; + +#[derive(Clone)] +pub struct AdminsState { + pub users: Arc, + pub sessions: Arc, +} + +pub fn admins_router(state: AdminsState) -> Router { + Router::new() + .route("/admins", get(list_admins).post(create_admin)) + .route( + "/admins/{id}", + get(get_admin).patch(patch_admin).delete(delete_admin), + ) + .with_state(state) +} + +// ---------------------------------------------------------------------------- +// DTOs +// ---------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +pub struct AdminDto { + pub id: AdminUserId, + pub username: String, + pub is_active: bool, + pub created_at: DateTime, + pub last_login_at: Option>, +} + +impl From for AdminDto { + fn from(r: AdminUserRow) -> Self { + Self { + id: r.id, + username: r.username, + is_active: r.is_active, + created_at: r.created_at, + last_login_at: r.last_login_at, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateAdminRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Deserialize, Default)] +pub struct PatchAdminRequest { + pub username: Option, + pub password: Option, + pub is_active: Option, +} + +// ---------------------------------------------------------------------------- +// Handlers +// ---------------------------------------------------------------------------- + +async fn list_admins( + State(state): State, +) -> Result>, AdminApiError> { + let rows = state.users.list().await?; + Ok(Json(rows.into_iter().map(Into::into).collect())) +} + +async fn get_admin( + State(state): State, + Path(id): Path, +) -> Result, AdminApiError> { + state + .users + .get(id) + .await? + .map(AdminDto::from) + .map(Json) + .ok_or(AdminApiError::NotFound(id)) +} + +async fn create_admin( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AdminApiError> { + let username = input.username.trim(); + validate_username(username)?; + validate_password(&input.password)?; + let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?; + let row = state.users.create(username, &hash).await?; + Ok((StatusCode::CREATED, Json(row.into()))) +} + +async fn patch_admin( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AdminApiError> { + // Verify the target exists upfront — keeps the error path uniform + // for "rename a missing user" etc. + let _ = state + .users + .get(id) + .await? + .ok_or(AdminApiError::NotFound(id))?; + + let mut latest: Option = None; + + if let Some(raw_username) = input.username.as_deref() { + let new_username = raw_username.trim(); + validate_username(new_username)?; + latest = Some(state.users.update_username(id, new_username).await?); + } + + if let Some(new_password) = input.password.as_deref() { + validate_password(new_password)?; + let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?; + latest = Some(state.users.update_password_hash(id, &hash).await?); + // Best practice: rotating your own password should still keep + // your session alive, so we don't wipe sessions here. (If we + // wanted "log everyone else out on password change", that'd be + // a `delete_for_user` + re-issue current session. Out of scope + // for the initial cut.) + } + + if let Some(new_active) = input.is_active { + // Last-active-admin guard: only when transitioning to inactive. + if !new_active { + let remaining = state.users.count_active_excluding(id).await?; + if remaining == 0 { + return Err(AdminApiError::LastActiveAdmin); + } + } + latest = Some(state.users.set_active(id, new_active).await?); + // Deactivation invalidates all of the user's sessions. Cheap + // and safer than waiting for sliding-window expiry. + if !new_active { + if let Err(err) = state.sessions.delete_for_user(id).await { + tracing::error!(?err, "failed to delete sessions for deactivated admin"); + } + } + } + + let row = match latest { + Some(r) => r, + None => state + .users + .get(id) + .await? + .ok_or(AdminApiError::NotFound(id))?, + }; + Ok(Json(row.into())) +} + +async fn delete_admin( + State(state): State, + Path(id): Path, +) -> Result { + let target = state + .users + .get(id) + .await? + .ok_or(AdminApiError::NotFound(id))?; + if target.is_active { + let remaining = state.users.count_active_excluding(id).await?; + if remaining == 0 { + return Err(AdminApiError::LastActiveAdmin); + } + } + state.users.delete(id).await?; + // Sessions cascade via FK; no explicit delete needed. + Ok(StatusCode::NO_CONTENT) +} + +// ---------------------------------------------------------------------------- +// Validation +// ---------------------------------------------------------------------------- + +fn validate_username(s: &str) -> Result<(), AdminApiError> { + if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX { + return Err(AdminApiError::InvalidUsername(format!( + "username must be {USERNAME_MIN}-{USERNAME_MAX} characters" + ))); + } + if !s + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')) + { + return Err(AdminApiError::InvalidUsername( + "username may contain only lowercase letters, digits, dot, underscore, and hyphen" + .to_string(), + )); + } + Ok(()) +} + +fn validate_password(s: &str) -> Result<(), AdminApiError> { + if s.chars().count() < PASSWORD_MIN { + return Err(AdminApiError::InvalidPassword(format!( + "password must be at least {PASSWORD_MIN} characters" + ))); + } + Ok(()) +} + +// ---------------------------------------------------------------------------- +// Errors +// ---------------------------------------------------------------------------- + +#[derive(Debug, thiserror::Error)] +pub enum AdminApiError { + #[error("admin user not found: {0}")] + NotFound(AdminUserId), + + #[error("{0}")] + InvalidUsername(String), + + #[error("{0}")] + InvalidPassword(String), + + #[error("cannot leave the system with zero active admins")] + LastActiveAdmin, + + #[error("failed to hash password: {0}")] + Hash(String), + + #[error("repository error: {0}")] + Repo(#[from] AdminUserRepositoryError), +} + +impl IntoResponse for AdminApiError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => { + (StatusCode::CONFLICT, self.to_string()) + } + Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => { + (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) + } + Self::Repo(AdminUserRepositoryError::NotFound(_)) => { + (StatusCode::NOT_FOUND, self.to_string()) + } + Self::Repo(AdminUserRepositoryError::Db(e)) => { + tracing::error!(error = %e, "admin_users db error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + Self::Hash(_) => { + tracing::error!(error = %self, "password hashing failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn username_validation_accepts_valid() { + for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] { + assert!(validate_username(u).is_ok(), "should accept {u}"); + } + } + + #[test] + fn username_validation_rejects_invalid() { + for u in ["", "a", "Alice", "user name", "user@domain", "user!"] { + assert!(validate_username(u).is_err(), "should reject {u:?}"); + } + let too_long = "x".repeat(33); + assert!(validate_username(&too_long).is_err()); + } + + #[test] + fn password_validation_enforces_min_length() { + assert!(validate_password("1234567").is_err()); + assert!(validate_password("12345678").is_ok()); + assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok()); + } +} diff --git a/crates/manager-core/src/auth.rs b/crates/manager-core/src/auth.rs new file mode 100644 index 0000000..7181b38 --- /dev/null +++ b/crates/manager-core/src/auth.rs @@ -0,0 +1,132 @@ +//! Pure auth helpers: password hashing, session-token generation, and +//! token-to-hash conversion. No DB, no HTTP — repos and middleware live +//! in their own modules. Keeping this surface pure also keeps the unit +//! tests fast (no Postgres needed). +//! +//! Hash algorithm is Argon2id with the OWASP default parameters +//! (`Argon2::default()`). Tokens are 32 cryptographically random bytes +//! base64-url-encoded for the wire; their SHA-256 (hex) is what hits the +//! sessions table. + +use argon2::password_hash::rand_core::OsRng as ArgonRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use rand::rngs::OsRng; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +/// Returned when the supplied password hash string isn't a valid PHC +/// Argon2id encoding. Only surfaces at bootstrap time when the operator +/// passes `PICLOUD_ADMIN_PASSWORD_HASH`. +#[derive(Debug, thiserror::Error)] +#[error("invalid Argon2id PHC hash")] +pub struct InvalidPasswordHash; + +/// Hash a raw password into an Argon2id PHC-formatted string suitable +/// for `admin_users.password_hash`. The output already encodes the salt +/// and parameters; nothing else needs to be persisted alongside it. +pub fn hash_password(raw: &str) -> Result { + let salt = SaltString::generate(&mut ArgonRng); + let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?; + Ok(hash.to_string()) +} + +/// Constant-ish-time verify of a raw password against a PHC hash. +/// Returns `false` for any error (including malformed stored hash) — +/// callers should treat that case identically to "wrong password" so +/// nothing leaks about why auth failed. +#[must_use] +pub fn verify_password(stored_hash: &str, raw: &str) -> bool { + let Ok(parsed) = PasswordHash::new(stored_hash) else { + return false; + }; + Argon2::default() + .verify_password(raw.as_bytes(), &parsed) + .is_ok() +} + +/// Validate that a string parses as a PHC Argon2id hash — used at +/// bootstrap to fail fast on malformed `PICLOUD_ADMIN_PASSWORD_HASH` +/// rather than write garbage into the DB and discover it at first login. +pub fn validate_password_hash(stored_hash: &str) -> Result<(), InvalidPasswordHash> { + PasswordHash::new(stored_hash).map_err(|_| InvalidPasswordHash)?; + Ok(()) +} + +/// Newly minted session token: `raw` goes to the client (cookie + JSON +/// response), `hash` is what gets stored. Raw is unrecoverable from hash +/// even if the DB leaks. +pub struct GeneratedToken { + pub raw: String, + pub hash: String, +} + +/// Generate a fresh session token (32 random bytes base64-url-encoded). +/// Always succeeds — `OsRng::fill_bytes` panics on entropy failure +/// instead of returning, but that's a non-recoverable system condition. +#[must_use] +pub fn generate_session_token() -> GeneratedToken { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let raw = URL_SAFE_NO_PAD.encode(bytes); + let hash = hash_token(&raw); + GeneratedToken { raw, hash } +} + +/// SHA-256(raw) as lower-case hex. Stable lookup key for +/// `admin_sessions.token_hash`. +#[must_use] +pub fn hash_token(raw: &str) -> String { + let digest = Sha256::digest(raw.as_bytes()); + hex(&digest) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_verify_roundtrip() { + let h = hash_password("correct horse battery staple").unwrap(); + assert!(verify_password(&h, "correct horse battery staple")); + assert!(!verify_password(&h, "wrong")); + } + + #[test] + fn verify_returns_false_on_malformed_hash() { + assert!(!verify_password("not-a-phc-string", "anything")); + } + + #[test] + fn validate_password_hash_accepts_phc() { + let h = hash_password("pw").unwrap(); + assert!(validate_password_hash(&h).is_ok()); + } + + #[test] + fn validate_password_hash_rejects_garbage() { + assert!(validate_password_hash("not a hash").is_err()); + } + + #[test] + fn generate_token_unique_and_hash_stable() { + let a = generate_session_token(); + let b = generate_session_token(); + assert_ne!(a.raw, b.raw, "tokens must be unique"); + assert_ne!(a.hash, b.hash, "hashes must differ"); + assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible"); + assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars"); + } +} diff --git a/crates/manager-core/src/auth_api.rs b/crates/manager-core/src/auth_api.rs new file mode 100644 index 0000000..9b635fc --- /dev/null +++ b/crates/manager-core/src/auth_api.rs @@ -0,0 +1,233 @@ +//! `/api/v1/admin/auth/*` — login, logout, who-am-I. +//! +//! Login mints an opaque session token, stores its SHA-256, sets the +//! `picloud_session` HttpOnly cookie, and also returns the raw token in +//! the JSON body for non-browser clients. The same token works as +//! `Authorization: Bearer …` afterward; there is no separate "API +//! token" concept yet. +//! +//! Logout deletes the session row regardless of whether the supplied +//! token matched anything (idempotent). `me` returns the row that the +//! middleware already attached to the request extensions. + +use axum::body::Body; +use axum::extract::{Extension, Request, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::middleware::from_fn_with_state; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::{get, post}; +use axum::Router; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use picloud_shared::AdminUserId; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::auth::{generate_session_token, hash_token, verify_password}; +use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; + +pub fn auth_router(state: AuthState) -> Router { + // /login + /logout are unguarded (login is how you get in; logout + // is idempotent). /me is guarded — by definition it needs to know + // who you are, so the middleware must run first. + let guarded = Router::new() + .route("/auth/me", get(me)) + .route_layer(from_fn_with_state(state.clone(), require_admin)); + + Router::new() + .route("/auth/login", post(login)) + .route("/auth/logout", post(logout)) + .merge(guarded) + .with_state(state) +} + +// ---------------------------------------------------------------------------- +// DTOs +// ---------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub user: AdminUserDto, + pub token: String, + pub expires_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct AdminUserDto { + pub id: AdminUserId, + pub username: String, +} + +// ---------------------------------------------------------------------------- +// Handlers +// ---------------------------------------------------------------------------- + +async fn login(State(state): State, Json(input): Json) -> Response { + // Always perform a verify, even on missing/inactive users, to flatten + // timing and prevent username enumeration. The dummy hash is a real + // Argon2id PHC string for "x" — the verify will simply fail. + const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks"; + + let creds = match state + .users + .get_credentials_by_username(&input.username) + .await + { + Ok(c) => c, + Err(err) => { + tracing::error!(?err, "admin_users credentials lookup failed"); + return internal_error(); + } + }; + + let (stored_hash, user_id, username, is_active) = match creds { + Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active), + None => (DUMMY_HASH.to_string(), None, String::new(), false), + }; + + let password_ok = verify_password(&stored_hash, &input.password); + if !password_ok || user_id.is_none() || !is_active { + return invalid_credentials(); + } + let user_id = user_id.unwrap(); + + let token = generate_session_token(); + let expires_at = Utc::now() + + ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24)); + + if let Err(err) = state + .sessions + .create(user_id, &token.hash, expires_at) + .await + { + tracing::error!(?err, "admin_sessions insert failed"); + return internal_error(); + } + if let Err(err) = state.users.touch_last_login(user_id).await { + // Non-fatal — log and continue. Login itself succeeded. + tracing::warn!(?err, "failed to touch admin last_login_at"); + } + + let mut headers = HeaderMap::new(); + headers.insert( + header::SET_COOKIE, + HeaderValue::from_str(&build_cookie(&token.raw, state.ttl)).unwrap_or_else(|_| { + // Cookie text is ASCII-clean by construction; this branch is + // unreachable in practice but the type signature requires it. + HeaderValue::from_static("") + }), + ); + + ( + StatusCode::OK, + headers, + Json(LoginResponse { + user: AdminUserDto { + id: user_id, + username, + }, + token: token.raw, + expires_at, + }), + ) + .into_response() +} + +async fn logout(State(state): State, req: Request) -> Response { + // Pull token without requiring a valid session (logout is idempotent + // and we still want to clear the cookie on the client side). + let token = extract_token_for_logout(&req); + if let Some(raw) = token { + let hash = hash_token(&raw); + if let Err(err) = state.sessions.delete(&hash).await { + tracing::error!(?err, "admin_sessions delete failed"); + // Still clear the cookie below. + } + } + + let mut headers = HeaderMap::new(); + headers.insert( + header::SET_COOKIE, + HeaderValue::from_static("picloud_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0"), + ); + (StatusCode::NO_CONTENT, headers).into_response() +} + +async fn me(Extension(admin): Extension) -> Json { + Json(AdminUserDto { + id: admin.id, + username: admin.username, + }) +} + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +fn build_cookie(raw_token: &str, ttl: std::time::Duration) -> String { + // Secure is on by default; flip to off for HTTP-only dev with + // PICLOUD_COOKIE_SECURE=0. The header-injected bearer token works + // either way, so this is purely for browsers that prefer the cookie + // path (e.g., direct API hits without the dashboard's auth.ts). + let secure = std::env::var("PICLOUD_COOKIE_SECURE").ok().is_none_or(|v| { + !matches!( + v.to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" + ) + }); + let secure_attr = if secure { "; Secure" } else { "" }; + format!( + "{SESSION_COOKIE}={raw_token}; HttpOnly{secure_attr}; SameSite=Lax; Path=/; Max-Age={}", + ttl.as_secs() + ) +} + +fn extract_token_for_logout(req: &Request) -> Option { + // Same precedence as the middleware — Authorization first, cookie + // fallback. Duplicated here because logout has to read the request + // before any middleware would run. + if let Some(value) = req.headers().get(header::AUTHORIZATION) { + if let Ok(s) = value.to_str() { + if let Some(token) = s.strip_prefix("Bearer ") { + let trimmed = token.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + } + if let Some(value) = req.headers().get(header::COOKIE) { + if let Ok(s) = value.to_str() { + for chunk in s.split(';') { + let chunk = chunk.trim(); + if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) { + if !rest.is_empty() { + return Some(rest.to_string()); + } + } + } + } + } + None +} + +fn invalid_credentials() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid credentials" })), + ) + .into_response() +} + +fn internal_error() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal error" })), + ) + .into_response() +} diff --git a/crates/manager-core/src/auth_bootstrap.rs b/crates/manager-core/src/auth_bootstrap.rs new file mode 100644 index 0000000..b044806 --- /dev/null +++ b/crates/manager-core/src/auth_bootstrap.rs @@ -0,0 +1,293 @@ +//! First-run admin seeding from env vars. Idempotent: if any admin +//! already exists, this is a no-op (and a warning is logged when the +//! env vars are also set, so the operator notices the inert state). +//! +//! On a fresh install, exactly one row is inserted from: +//! - `PICLOUD_ADMIN_USERNAME` (required) +//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR +//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in) +//! +//! After that initial seed, the env vars become inert. This is +//! deliberate: the env var is a one-time setup hatch, not a permanent +//! override (which would let anyone with systemd/compose access change +//! any admin's password without authentication). Recovery is the CLI +//! subcommand `picloud admin reset-password `. +//! +//! The env-var reading is factored into `BootstrapEnv::from_process` +//! so the core logic stays pure (and testable) — the only side effect +//! in `bootstrap_first_admin` is the DB write and a tracing log. + +use tracing::{info, warn}; + +use crate::admin_user_repo::AdminUserRepository; +use crate::auth::{hash_password, validate_password_hash}; + +pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME"; +pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD"; +pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH"; + +#[derive(Debug, thiserror::Error)] +pub enum BootstrapError { + #[error("repository error: {0}")] + Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError), + + #[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")] + MissingUsername, + + #[error( + "no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}" + )] + MissingPassword, + + #[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")] + InvalidHash, + + #[error("failed to hash password: {0}")] + HashFailure(String), +} + +/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap +/// env vars. Read from the live process with `from_process`, or build +/// directly in tests to keep them free of process-env races. +#[derive(Debug, Default, Clone)] +pub struct BootstrapEnv { + pub username: Option, + pub password: Option, + pub password_hash: Option, +} + +impl BootstrapEnv { + /// Snapshot the bootstrap env vars from the current process. + #[must_use] + pub fn from_process() -> Self { + Self { + username: std::env::var(ENV_USERNAME).ok(), + password: std::env::var(ENV_PASSWORD).ok(), + password_hash: std::env::var(ENV_PASSWORD_HASH).ok(), + } + } + + fn any_set(&self) -> bool { + self.username.is_some() || self.password.is_some() || self.password_hash.is_some() + } +} + +/// Run the bootstrap. Reads env vars from the live process — the +/// canonical wiring for the binary. +pub async fn bootstrap_first_admin( + repo: &R, +) -> Result<(), BootstrapError> { + bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await +} + +/// Run the bootstrap against an explicit env. Used by tests to keep +/// the bootstrap logic independent of process state. +pub async fn bootstrap_first_admin_with( + repo: &R, + env: BootstrapEnv, +) -> Result<(), BootstrapError> { + if repo.count_active().await? > 0 { + if env.any_set() { + warn!( + "{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \ + already populated — env values ignored. Use \ + `picloud admin reset-password ` to change a password." + ); + } + return Ok(()); + } + + let username = env.username.ok_or(BootstrapError::MissingUsername)?; + + let password_hash = match (env.password_hash, env.password) { + (Some(hash), maybe_raw) => { + if maybe_raw.is_some() { + warn!( + "both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \ + using the pre-computed hash; raw password ignored." + ); + } + validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?; + hash + } + (None, Some(raw)) => { + hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))? + } + (None, None) => return Err(BootstrapError::MissingPassword), + }; + + repo.create(&username, &password_hash).await?; + info!(username = %username, "bootstrapped initial admin user"); + Ok(()) +} + +#[cfg(test)] +mod tests { + //! These tests use an in-memory `AdminUserRepository` and the + //! `bootstrap_first_admin_with` overload so they never touch + //! process-global env vars. They can run in parallel safely. + + use super::*; + use async_trait::async_trait; + use chrono::Utc; + use picloud_shared::AdminUserId; + use std::sync::Mutex; + + use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow}; + + #[derive(Default)] + struct InMemoryRepo { + rows: Mutex>, + } + + #[async_trait] + impl AdminUserRepository for InMemoryRepo { + async fn get( + &self, + _id: AdminUserId, + ) -> Result, AdminUserRepositoryError> { + unimplemented!() + } + async fn get_by_username( + &self, + _u: &str, + ) -> Result, AdminUserRepositoryError> { + unimplemented!() + } + async fn get_credentials_by_username( + &self, + _u: &str, + ) -> Result, AdminUserRepositoryError> { + unimplemented!() + } + async fn list(&self) -> Result, AdminUserRepositoryError> { + unimplemented!() + } + async fn create( + &self, + username: &str, + _password_hash: &str, + ) -> Result { + let row = AdminUserRow { + id: AdminUserId::new(), + username: username.to_string(), + is_active: true, + created_at: Utc::now(), + updated_at: Utc::now(), + last_login_at: None, + }; + self.rows.lock().unwrap().push(row.clone()); + Ok(row) + } + async fn update_username( + &self, + _i: AdminUserId, + _u: &str, + ) -> Result { + unimplemented!() + } + async fn update_password_hash( + &self, + _i: AdminUserId, + _h: &str, + ) -> Result { + unimplemented!() + } + async fn set_active( + &self, + _i: AdminUserId, + _a: bool, + ) -> Result { + unimplemented!() + } + async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> { + unimplemented!() + } + async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> { + unimplemented!() + } + async fn count_active(&self) -> Result { + Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX)) + } + async fn count_active_excluding( + &self, + _i: AdminUserId, + ) -> Result { + unimplemented!() + } + } + + #[tokio::test] + async fn empty_db_creates_admin_from_raw_password() { + let repo = InMemoryRepo::default(); + let env = BootstrapEnv { + username: Some("alice".into()), + password: Some("supersecret".into()), + password_hash: None, + }; + bootstrap_first_admin_with(&repo, env).await.unwrap(); + assert_eq!(repo.rows.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn empty_db_with_pre_hashed_password_succeeds() { + let repo = InMemoryRepo::default(); + let prehashed = hash_password("pw").unwrap(); + let env = BootstrapEnv { + username: Some("alice".into()), + password: None, + password_hash: Some(prehashed), + }; + bootstrap_first_admin_with(&repo, env).await.unwrap(); + assert_eq!(repo.rows.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn populated_db_is_noop() { + let repo = InMemoryRepo::default(); + repo.create("seeded", "x").await.unwrap(); + let env = BootstrapEnv { + username: Some("alice".into()), + password: Some("supersecret".into()), + password_hash: None, + }; + bootstrap_first_admin_with(&repo, env).await.unwrap(); + assert_eq!(repo.rows.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn missing_username_fails() { + let repo = InMemoryRepo::default(); + let env = BootstrapEnv { + username: None, + password: Some("supersecret".into()), + password_hash: None, + }; + let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err(); + assert!(matches!(err, BootstrapError::MissingUsername)); + } + + #[tokio::test] + async fn missing_password_fails() { + let repo = InMemoryRepo::default(); + let env = BootstrapEnv { + username: Some("alice".into()), + password: None, + password_hash: None, + }; + let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err(); + assert!(matches!(err, BootstrapError::MissingPassword)); + } + + #[tokio::test] + async fn invalid_hash_fails() { + let repo = InMemoryRepo::default(); + let env = BootstrapEnv { + username: Some("alice".into()), + password: None, + password_hash: Some("not a phc hash".into()), + }; + let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err(); + assert!(matches!(err, BootstrapError::InvalidHash)); + } +} diff --git a/crates/manager-core/src/auth_middleware.rs b/crates/manager-core/src/auth_middleware.rs new file mode 100644 index 0000000..fd23daf --- /dev/null +++ b/crates/manager-core/src/auth_middleware.rs @@ -0,0 +1,185 @@ +//! `require_admin` axum middleware: gates a router on a valid admin +//! session. Accepts the token from either the `picloud_session` cookie +//! or an `Authorization: Bearer …` header — same token system serves +//! the dashboard and CLI/CI clients. +//! +//! On success, injects `AuthedAdmin` as a request extension so handlers +//! can `Extension` to know who's calling. On failure, +//! returns 401 with a generic JSON body (no enumeration about whether +//! the token was wrong vs. the user was deactivated). + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{header, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Json, Response}; +use chrono::Utc; +use picloud_shared::AdminUserId; +use serde_json::json; + +use crate::admin_session_repo::AdminSessionRepository; +use crate::admin_user_repo::AdminUserRepository; +use crate::auth::hash_token; + +pub const SESSION_COOKIE: &str = "picloud_session"; + +/// Shared state for auth: the two repos plus the configured sliding +/// session TTL. Cheap to clone (`Arc` everywhere). +#[derive(Clone)] +pub struct AuthState { + pub users: Arc, + pub sessions: Arc, + pub ttl: Duration, +} + +/// Request-extension type that authenticated handlers extract via +/// `Extension`. Available only inside guarded routers. +#[derive(Debug, Clone)] +pub struct AuthedAdmin { + pub id: AdminUserId, + pub username: String, +} + +/// Middleware function. Wire with +/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`. +pub async fn require_admin( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let Some(token) = extract_token(&req) else { + return unauthorized(); + }; + let token_hash = hash_token(&token); + + let lookup = match state.sessions.lookup(&token_hash).await { + Ok(Some(lookup)) => lookup, + Ok(None) => return unauthorized(), + Err(err) => { + tracing::error!(?err, "admin_sessions lookup failed"); + return internal_error(); + } + }; + + // Resolve the user. A deleted user is impossible here (FK cascade + // wipes their sessions), but a deactivated user still needs to be + // rejected — and so does the edge case of a session predating the + // deactivate (we wipe their sessions on deactivate, but a race + // could land a request in flight). + let user = match state.users.get(lookup.user_id).await { + Ok(Some(u)) if u.is_active => u, + Ok(_) => return unauthorized(), + Err(err) => { + tracing::error!(?err, "admin_users lookup failed"); + return internal_error(); + } + }; + + // Sliding window bump. Inline (not fire-and-forget) so a DB blip + // surfaces as a request error rather than silent stale sessions. + let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default(); + if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await { + tracing::error!(?err, "admin_sessions touch failed"); + return internal_error(); + } + + req.extensions_mut().insert(AuthedAdmin { + id: user.id, + username: user.username, + }); + next.run(req).await +} + +/// Pull the bearer token out of an `Authorization` header (preferred) +/// or the `picloud_session` cookie (fallback for browser clients). +fn extract_token(req: &Request) -> Option { + if let Some(value) = req.headers().get(header::AUTHORIZATION) { + if let Ok(s) = value.to_str() { + if let Some(token) = s.strip_prefix("Bearer ") { + let trimmed = token.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + } + if let Some(value) = req.headers().get(header::COOKIE) { + if let Ok(s) = value.to_str() { + for chunk in s.split(';') { + let chunk = chunk.trim(); + if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) { + if !rest.is_empty() { + return Some(rest.to_string()); + } + } + } + } + } + None +} + +fn unauthorized() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "authentication required" })), + ) + .into_response() +} + +fn internal_error() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal error" })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::Request; + + fn req_with_header(name: &str, value: &str) -> Request { + Request::builder() + .header(name, value) + .body(Body::empty()) + .unwrap() + } + + #[test] + fn extracts_bearer_token() { + let r = req_with_header("authorization", "Bearer abc123"); + assert_eq!(extract_token(&r).as_deref(), Some("abc123")); + } + + #[test] + fn ignores_bearer_with_no_token() { + let r = req_with_header("authorization", "Bearer "); + assert_eq!(extract_token(&r), None); + } + + #[test] + fn extracts_cookie_token() { + let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux"); + assert_eq!(extract_token(&r).as_deref(), Some("xyz")); + } + + #[test] + fn bearer_wins_over_cookie() { + let r = Request::builder() + .header("authorization", "Bearer header-token") + .header("cookie", "picloud_session=cookie-token") + .body(Body::empty()) + .unwrap(); + assert_eq!(extract_token(&r).as_deref(), Some("header-token")); + } + + #[test] + fn returns_none_when_neither_present() { + let r = Request::builder().body(Body::empty()).unwrap(); + assert_eq!(extract_token(&r), None); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 0fca7f6..da55f0f 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -4,7 +4,14 @@ //! the same DB for now; once we add caching and per-node ingress, the //! manager will publish change events. +pub mod admin_session_repo; +pub mod admin_user_repo; +pub mod admin_users_api; pub mod api; +pub mod auth; +pub mod auth_api; +pub mod auth_bootstrap; +pub mod auth_middleware; pub mod log_sink; pub mod migrations; pub mod repo; @@ -13,7 +20,21 @@ pub mod route_repo; pub mod sandbox; pub mod scheduler; +pub use admin_session_repo::{ + AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError, + PostgresAdminSessionRepository, +}; +pub use admin_user_repo::{ + AdminUserCredentials, AdminUserRepository, AdminUserRepositoryError, AdminUserRow, + PostgresAdminUserRepository, +}; +pub use admin_users_api::{admins_router, AdminsState}; pub use api::{admin_router, AdminState}; +pub use auth_api::auth_router; +pub use auth_bootstrap::{ + bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError, +}; +pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE}; pub use log_sink::PostgresExecutionLogSink; pub use repo::{ ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index c1bd908..a31ffb8 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -6,10 +6,13 @@ use std::sync::Arc; use std::time::Duration; +use axum::middleware::from_fn_with_state; use axum::{routing::get, Json, Router}; use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ - admin_router, compile_routes, migrations, route_admin_router, AdminState, + admin_router, admins_router, auth_router, compile_routes, migrations, require_admin, + route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, + AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, }; @@ -24,6 +27,38 @@ use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use tower_http::trace::TraceLayer; +/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set. +const DEFAULT_SESSION_TTL_HOURS: u64 = 24; + +/// Bundles the auth-related dependencies that both `build_app` and the +/// startup bootstrap need. Built once in `main.rs` from the shared pool. +pub struct AuthDeps { + pub users: Arc, + pub sessions: Arc, + pub ttl: Duration, +} + +impl AuthDeps { + /// Construct from a pool with the binary's standard defaults. + #[must_use] + pub fn from_pool(pool: PgPool) -> Self { + Self { + users: Arc::new(PostgresAdminUserRepository::new(pool.clone())), + sessions: Arc::new(PostgresAdminSessionRepository::new(pool)), + ttl: read_session_ttl(), + } + } +} + +fn read_session_ttl() -> Duration { + let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|h| *h > 0) + .unwrap_or(DEFAULT_SESSION_TTL_HOURS); + Duration::from_secs(hours * 3600) +} + /// Compose the manager + orchestrator routes on top of a shared /// Postgres pool, returning an Axum router ready to be served. /// @@ -31,7 +66,15 @@ use tower_http::trace::TraceLayer; /// is mounted by Caddy at `/admin/*` (its base path). Anything else /// falls through to the user-route table — user scripts can bind to /// arbitrary paths (subject to the reserved-prefix list). -pub async fn build_app(pool: PgPool) -> anyhow::Result { +/// +/// `auth` carries the admin user/session repositories and the +/// configured session TTL. The manager-side admin endpoints +/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`, +/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by +/// the `require_admin` middleware. The data plane +/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`, +/// `/version`) stays open — it's the public ingress for user scripts. +pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { let engine = Arc::new(Engine::new(Limits::default())); let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone())); @@ -68,9 +111,29 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result { routes: route_table, }; + let auth_state = AuthState { + users: auth.users.clone(), + sessions: auth.sessions.clone(), + ttl: auth.ttl, + }; + let admins_state = AdminsState { + users: auth.users, + sessions: auth.sessions, + }; + + // /admin/auth/login + /logout are unguarded by design (login is how + // you get in). /admin/auth/me applies the middleware internally so + // the same Router::with_state machinery composes cleanly. Everything + // else under /admin gets the require_admin layer. + let guarded_admin = Router::new() + .merge(admin_router(admin)) + .merge(route_admin_router(route_admin)) + .merge(admins_router(admins_state)) + .layer(from_fn_with_state(auth_state.clone(), require_admin)); + let api_v1 = Router::new() - .nest("/admin", admin_router(admin)) - .nest("/admin", route_admin_router(route_admin)) + .nest("/admin", auth_router(auth_state)) + .nest("/admin", guarded_admin) .merge(data_plane_router(data_plane.clone())); Ok(Router::new() diff --git a/crates/picloud/src/main.rs b/crates/picloud/src/main.rs index a7296fb..5ea28cc 100644 --- a/crates/picloud/src/main.rs +++ b/crates/picloud/src/main.rs @@ -1,17 +1,36 @@ //! PiCloud all-in-one binary — see `lib.rs` for the actual app //! composition; this file is only the runtime shell (env config, -//! logger, migrations, listener). +//! logger, migrations, listener) plus the small `admin` CLI subcommand +//! used for out-of-band password recovery. +use std::io::{BufRead, Write}; use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; -use picloud::{build_app, init_db}; -use picloud_manager_core::migrations; +use picloud::{build_app, init_db, AuthDeps}; +use picloud_manager_core::{ + auth::{hash_password, validate_password_hash}, + bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository, +}; use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> anyhow::Result<()> { init_tracing(); + // Subcommand dispatch — `picloud admin reset-password `. + // Kept handwritten to avoid pulling clap in just for one verb. Falls + // through to the server when no subcommand is given. + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("admin") { + return run_admin_cli(&args[2..]).await; + } + + run_server().await +} + +async fn run_server() -> anyhow::Result<()> { let addr: SocketAddr = std::env::var("PICLOUD_BIND") .unwrap_or_else(|_| "0.0.0.0:8080".into()) .parse()?; @@ -22,7 +41,15 @@ async fn main() -> anyhow::Result<()> { migrations::run(&pool).await?; tracing::info!("migrations applied"); - let app = build_app(pool).await?; + let auth = AuthDeps::from_pool(pool.clone()); + bootstrap_first_admin(&*auth.users).await?; + + // Background session-prune sweep. Cheap; keeps the table from + // growing unbounded. Expired rows are also rejected at lookup time, + // so a delayed sweep can't extend session lifetimes. + spawn_session_pruner(auth.sessions.clone()); + + let app = build_app(pool, auth).await?; let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!(%addr, "picloud all-in-one listening"); @@ -33,6 +60,112 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn spawn_session_pruner(sessions: Arc) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(600)); + // First tick fires immediately; skip it so we don't race startup. + ticker.tick().await; + loop { + ticker.tick().await; + match sessions.prune_expired().await { + Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"), + Ok(_) => {} + Err(err) => tracing::warn!(?err, "admin session prune failed"), + } + } + }); +} + +// ---------------------------------------------------------------------------- +// `admin` subcommand +// ---------------------------------------------------------------------------- + +async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> { + match args.first().map(String::as_str) { + Some("reset-password") => { + let username = args.get(1).ok_or_else(|| { + anyhow::anyhow!( + "usage: picloud admin reset-password [--password-hash ]" + ) + })?; + // Optional inline hash via --password-hash ; otherwise + // read a raw password from stdin. + let hash_arg = parse_flag(&args[2..], "--password-hash"); + cmd_reset_password(username, hash_arg).await + } + Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")), + None => Err(anyhow::anyhow!( + "usage: picloud admin reset-password " + )), + } +} + +fn parse_flag(args: &[String], name: &str) -> Option { + let mut it = args.iter(); + while let Some(a) = it.next() { + if a == name { + return it.next().cloned(); + } + } + None +} + +async fn cmd_reset_password(username: &str, password_hash: Option) -> anyhow::Result<()> { + let database_url = + std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?; + let pool = init_db(&database_url).await?; + migrations::run(&pool).await?; + + let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone()); + let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool); + + let target = users + .get_by_username(username) + .await? + .ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?; + + let hash = if let Some(h) = password_hash { + validate_password_hash(&h) + .map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?; + h + } else { + let raw = prompt_password_from_stdin()?; + hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))? + }; + + users.update_password_hash(target.id, &hash).await?; + // Recovery implies the operator already lost control of the account; + // re-activate it (so a deactivated admin can also recover) and wipe + // any pre-existing sessions in case the original holder is still + // signed in elsewhere. + if !target.is_active { + users.set_active(target.id, true).await?; + } + let dropped = sessions.delete_for_user(target.id).await?; + + println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true."); + Ok(()) +} + +fn prompt_password_from_stdin() -> anyhow::Result { + eprint!("New password (will be read from stdin, no echo): "); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?; + let pw = line.trim_end_matches(['\n', '\r']).to_string(); + if pw.is_empty() { + return Err(anyhow::anyhow!("password must not be empty")); + } + Ok(pw) +} + +// ---------------------------------------------------------------------------- +// Misc +// ---------------------------------------------------------------------------- + fn init_tracing() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index 74554a0..0767d92 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -17,9 +17,35 @@ use axum_test::TestServer; use serde_json::{json, Value}; use sqlx::PgPool; +/// Build the all-in-one app over the test pool, seed a single admin +/// directly through the repo (bypassing the env-var bootstrap path so +/// tests don't contaminate the process environment), log in, and bake +/// the bearer token into the TestServer as a default header so every +/// request in the test passes the `require_admin` middleware. async fn server(pool: PgPool) -> TestServer { - let app = picloud::build_app(pool).await.expect("build_app"); - TestServer::new(app).expect("TestServer should build") + use picloud_manager_core::auth::hash_password; + + let auth = picloud::AuthDeps::from_pool(pool.clone()); + let hash = hash_password("test-pw").expect("hash"); + auth.users + .create("test-admin", &hash) + .await + .expect("seed admin"); + + let app = picloud::build_app(pool, auth).await.expect("build_app"); + let mut server = TestServer::new(app).expect("TestServer should build"); + + let resp = server + .post("/api/v1/admin/auth/login") + .json(&json!({ "username": "test-admin", "password": "test-pw" })) + .await; + resp.assert_status_ok(); + let token = resp.json::()["token"] + .as_str() + .expect("login should return token") + .to_string(); + server.add_header("authorization", format!("Bearer {token}")); + server } // ============================================================================ @@ -705,7 +731,7 @@ async fn version_includes_public_base_url(pool: PgPool) { let v: Value = r.json(); assert!(v["public_base_url"].is_string()); assert_eq!(v["api"], 1); - assert_eq!(v["schema"], 3); + assert_eq!(v["schema"], 4); assert_eq!(v["sdk"], "1.1"); } diff --git a/crates/shared/src/ids.rs b/crates/shared/src/ids.rs index b75f98b..5d4ca0d 100644 --- a/crates/shared/src/ids.rs +++ b/crates/shared/src/ids.rs @@ -50,3 +50,4 @@ macro_rules! id_type { id_type!(ScriptId); id_type!(ExecutionId); id_type!(RequestId); +id_type!(AdminUserId); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 5fbd0ce..18aedbc 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -16,7 +16,7 @@ pub mod version; pub use error::Error; pub use execution_log::{ExecutionLog, ExecutionStatus}; -pub use ids::{ExecutionId, RequestId, ScriptId}; +pub use ids::{AdminUserId, ExecutionId, RequestId, ScriptId}; pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use route::{HostKind, PathKind, Route}; pub use sandbox::ScriptSandbox; diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 41286b5..ac1eec2 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -5,6 +5,11 @@ // the same Caddy upstream so the "Test invoke" panel can hit it // without any cross-origin gymnastics. +import { goto } from '$app/navigation'; +import { base } from '$app/paths'; +import { browser } from '$app/environment'; +import { clearSession, getToken, setSession, type AdminUser } from './auth'; + export interface ScriptSandbox { max_operations?: number; max_string_size?: number; @@ -134,12 +139,26 @@ export class ApiError extends Error { } async function adminRequest(path: string, init?: RequestInit): Promise { - const res = await fetch(path, { - ...init, - headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) } - }); + const headers: Record = { + 'content-type': 'application/json', + ...((init?.headers as Record) ?? {}) + }; + const tok = getToken(); + if (tok && !headers['authorization']) { + headers['authorization'] = `Bearer ${tok}`; + } + const res = await fetch(path, { ...init, headers }); const text = await res.text(); const parsed: unknown = text ? safeJson(text) : null; + if (res.status === 401) { + // Token gone stale or never present. Drop any cached session + // and bounce to login — unless we're already on it, in which + // case throw and let the login form render the error. + clearSession(); + if (browser && !window.location.pathname.endsWith('/login')) { + void goto(`${base}/login`); + } + } if (!res.ok) { const message = (parsed && typeof parsed === 'object' && 'error' in parsed @@ -158,11 +177,76 @@ function safeJson(text: string): unknown { } } +export interface AdminUserRecord { + id: string; + username: string; + is_active: boolean; + created_at: string; + last_login_at: string | null; +} + +export interface CreateAdminInput { + username: string; + password: string; +} + +export interface PatchAdminInput { + username?: string; + password?: string; + is_active?: boolean; +} + +interface LoginResponse { + user: AdminUser; + token: string; + expires_at: string; +} + export const api = { health: () => fetch('/healthz').then((r) => r.text()), version: () => adminRequest('/version'), + auth: { + login: async (username: string, password: string): Promise => { + const r = await adminRequest('/api/v1/admin/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + setSession(r.user, r.token); + return r.user; + }, + logout: async (): Promise => { + try { + await adminRequest('/api/v1/admin/auth/logout', { method: 'POST' }); + } finally { + // Always clear locally — logout is idempotent server-side + // and we don't want a network blip to strand the SPA in + // a "logged out on server, still logged in client-side" + // state. + clearSession(); + } + }, + me: () => adminRequest('/api/v1/admin/auth/me') + }, + + admins: { + list: () => adminRequest('/api/v1/admin/admins'), + get: (id: string) => adminRequest(`/api/v1/admin/admins/${id}`), + create: (input: CreateAdminInput) => + adminRequest('/api/v1/admin/admins', { + method: 'POST', + body: JSON.stringify(input) + }), + update: (id: string, input: PatchAdminInput) => + adminRequest(`/api/v1/admin/admins/${id}`, { + method: 'PATCH', + body: JSON.stringify(input) + }), + remove: (id: string) => + adminRequest(`/api/v1/admin/admins/${id}`, { method: 'DELETE' }) + }, + routes: { listForScript: (scriptId: string) => adminRequest(`/api/v1/admin/scripts/${scriptId}/routes`), diff --git a/dashboard/src/lib/auth.ts b/dashboard/src/lib/auth.ts new file mode 100644 index 0000000..02e2a2d --- /dev/null +++ b/dashboard/src/lib/auth.ts @@ -0,0 +1,60 @@ +// Session state for the dashboard. Backed by a pair of Svelte stores +// plus a tiny localStorage echo so a page reload doesn't sign you out. +// +// The bearer token doubles as the cookie value on the server side, so +// in browsers that honor the Set-Cookie response the cookie path "just +// works"; the token-in-localStorage path covers the rest (HTTP dev, API +// clients impersonating the dashboard) by being injected into the +// Authorization header in api.ts. + +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface AdminUser { + id: string; + username: string; +} + +const TOKEN_KEY = 'picloud.admin.token'; + +function readStoredToken(): string | null { + if (!browser) return null; + try { + return localStorage.getItem(TOKEN_KEY); + } catch { + return null; + } +} + +function writeStoredToken(value: string | null) { + if (!browser) return; + try { + if (value === null) localStorage.removeItem(TOKEN_KEY); + else localStorage.setItem(TOKEN_KEY, value); + } catch { + // Non-fatal: localStorage can be disabled. The session will + // just not survive page reloads, but the in-memory store still + // works for the current SPA lifetime. + } +} + +export const token = writable(readStoredToken()); +export const currentUser = writable(null); + +token.subscribe((value) => writeStoredToken(value)); + +/** Snapshot of the current token without subscribing — used by the + * fetch wrapper. Returns null when no admin is logged in. */ +export function getToken(): string | null { + return get(token); +} + +export function setSession(user: AdminUser, raw_token: string) { + currentUser.set(user); + token.set(raw_token); +} + +export function clearSession() { + currentUser.set(null); + token.set(null); +} diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte index ee41f6a..5cc7ca0 100644 --- a/dashboard/src/routes/+layout.svelte +++ b/dashboard/src/routes/+layout.svelte @@ -1,6 +1,44 @@
@@ -8,10 +46,22 @@ PiCloud +
+ {#if user} +
+ {user.username} + +
+ {/if}
- {@render children?.()} + {#if booting} +

Loading…

+ {:else} + {@render children?.()} + {/if}
@@ -45,6 +95,11 @@ text-decoration: none; } + nav { + display: flex; + gap: 1.5rem; + } + nav a { color: #94a3b8; text-decoration: none; @@ -55,6 +110,36 @@ color: #e2e8f0; } + .spacer { + flex: 1; + } + + .usermenu { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.875rem; + } + + .username { + color: #cbd5e1; + } + + .logout { + background: transparent; + color: #94a3b8; + border: 1px solid #334155; + padding: 0.35rem 0.75rem; + border-radius: 0.375rem; + cursor: pointer; + font-size: 0.8rem; + } + + .logout:hover { + background: #1e293b; + color: #e2e8f0; + } + main { flex: 1; padding: 2rem; @@ -63,4 +148,8 @@ margin: 0 auto; box-sizing: border-box; } + + .boot { + color: #64748b; + } diff --git a/dashboard/src/routes/admins/+page.svelte b/dashboard/src/routes/admins/+page.svelte new file mode 100644 index 0000000..496b829 --- /dev/null +++ b/dashboard/src/routes/admins/+page.svelte @@ -0,0 +1,687 @@ + + +
+

Admin Users

+ +
+ +{#if banner} + +{/if} + +{#if loadError} +
+ {loadError} + +
+{:else if admins.length === 0} +

No admin users yet. Add one to get started.

+{:else} +
+
+
Username
+
Status
+
Created
+
Last login
+
+
+ {#each admins as row (row.id)} +
+
+ {row.username} + {#if me && me.id === row.id} + (you) + {/if} +
+
+ {#if row.is_active} + ● Active + {:else} + ○ Inactive + {/if} +
+
{shortDate(row.created_at)}
+
{relative(row.last_login_at)}
+
+ + {#if actionsOpenFor === row.id} + + {/if} +
+
+ {/each} +
+{/if} + + +{#if createOpen} + +{/if} + + +{#if passwordTarget} + +{/if} + + +{#if deleteTarget} + +{/if} + + diff --git a/dashboard/src/routes/login/+page.svelte b/dashboard/src/routes/login/+page.svelte new file mode 100644 index 0000000..eefc0dc --- /dev/null +++ b/dashboard/src/routes/login/+page.svelte @@ -0,0 +1,172 @@ + + + + + diff --git a/serverless_cloud_blueprint.md b/serverless_cloud_blueprint.md index dcbcddb..b15dd9f 100644 --- a/serverless_cloud_blueprint.md +++ b/serverless_cloud_blueprint.md @@ -732,9 +732,11 @@ volumes: --- -## 11.4 Admin Auth (Phase 3a) +## 11.4 Admin Auth (Phase 3a) — Shipped -**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Today the surface is open — anyone reaching the bound port can create, edit, and delete scripts. +**Status**: shipped. Implementation lives in `crates/manager-core/src/{auth,auth_*,admin_user_repo,admin_session_repo,admin_users_api}.rs`; migration `0004_admin_auth.sql`. + +**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Before this phase the surface was open — anyone reaching the bound port could create, edit, and delete scripts. **Why per-user, not a shared secret**: shared admin passwords get shared between humans, leave no audit trail, and can't be revoked per-person. Per-user accounts solve all three. The initial cut deliberately stops there — no roles, no per-app permissions — because that scope is small enough to ship in a single phase without blocking Phase 3b. Roles + per-app permissions are queued for v1.3+. @@ -746,26 +748,29 @@ We reserve the unqualified **`users`** table for the v1.1+ Rhai SDK feature (scr ```sql CREATE TABLE admin_users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, -- Argon2id - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - last_login_at TIMESTAMP + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, -- Argon2id (PHC string) + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ ); CREATE TABLE admin_sessions ( - token_hash TEXT PRIMARY KEY, -- SHA-256 of the bearer token; raw token only exists in the login response + cookie - user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT NOW(), - expires_at TIMESTAMP NOT NULL, - last_used_at TIMESTAMP DEFAULT NOW() + token_hash TEXT PRIMARY KEY, -- SHA-256(hex) of the bearer token; raw token only exists in the login response + cookie + user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_admin_sessions_user ON admin_sessions(user_id); -CREATE INDEX idx_admin_sessions_expiry ON admin_sessions(expires_at); +CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id); +CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at); ``` +`is_active` was added to the shipped cut so admins can be deactivated (login rejected, sessions wiped) without losing audit history; deletion still cascades sessions through the FK. + **Password hashing**: Argon2id with default OWASP parameters. This also resolves the v1.1+ open question about user-password hashing (§10) — the platform settles on Argon2id once, here. ### Bootstrap @@ -814,13 +819,17 @@ Companion endpoints: ``` GET /api/v1/admin/admins — list -POST /api/v1/admin/admins — create +POST /api/v1/admin/admins — create ({ username, password }) GET /api/v1/admin/admins/{id} — get -PATCH /api/v1/admin/admins/{id} — update (username, password) -DELETE /api/v1/admin/admins/{id} — delete (rejected if it would leave zero admins) +PATCH /api/v1/admin/admins/{id} — update ({ username?, password?, is_active? }) +DELETE /api/v1/admin/admins/{id} — delete ``` -Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. +Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. The PATCH and DELETE handlers both refuse to leave the system with zero active admins (`422 Unprocessable Entity` with a clear message); PATCH that transitions `is_active` from true to false also wipes that user's sessions immediately. + +Validation: username `^[a-z0-9._-]{2,32}$`, password minimum 8 characters (no complexity rules — follows NIST 800-63B guidance). + +Dashboard surface: `/admin/login` (unauthed), `/admin/admins` (user list with add / change-password / deactivate / reactivate / delete actions per row). The top-bar shows the logged-in admin and a logout button. Token is held in a Svelte store with a localStorage echo so a page refresh doesn't sign you out; cookie-based auth works in parallel for non-SPA browser hits. ### Forward Compatibility @@ -1031,7 +1040,7 @@ The scripts and routes endpoints keep their existing shape — this avoids forci Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive. -**3a. Admin auth** — see section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC. +**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC. **3b. Multi-app scoping** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.