diff --git a/Cargo.toml b/Cargo.toml index fdb73ac..6032968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"] +members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search", "crates/auth"] [workspace.package] edition = "2024" @@ -24,3 +24,7 @@ tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } meilisearch-sdk = "0.33" +argon2 = "0.5" +tower-sessions = "0.14" +tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } +rpassword = "7" diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 5eda5af..720d7bb 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -8,6 +8,11 @@ rust-version.workspace = true axum.workspace = true serde.workspace = true utoipa.workspace = true +time.workspace = true +tower-sessions.workspace = true +tower-sessions-sqlx-store.workspace = true +sqlx.workspace = true +auth = { path = "../auth" } db = { path = "../db" } domain = { path = "../domain" } @@ -16,4 +21,3 @@ tokio.workspace = true tower.workspace = true http-body-util.workspace = true serde_json.workspace = true -sqlx.workspace = true diff --git a/crates/api/src/admin.rs b/crates/api/src/admin.rs new file mode 100644 index 0000000..3b55eb4 --- /dev/null +++ b/crates/api/src/admin.rs @@ -0,0 +1,179 @@ +//! Admin (authenticated) surface: login/logout/session, user listing, and publishing. + +use auth::{AuthUser, Authorized, ManageUsers, PublishObjects}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, +}; +use domain::{AuditActor, ObjectId, Visibility}; +use serde::{Deserialize, Serialize}; +use tower_sessions::Session; +use utoipa::ToSchema; + +use crate::AppState; + +/// Credentials for password login. +#[derive(Deserialize, ToSchema)] +pub(crate) struct LoginRequest { + pub email: String, + pub password: String, +} + +/// A user as exposed on the admin surface (no password material). +#[derive(Serialize, ToSchema)] +pub(crate) struct UserView { + pub id: String, + pub email: String, + pub role: String, +} + +/// Desired visibility for a publish/unpublish request. +#[derive(Deserialize, ToSchema)] +pub(crate) struct VisibilityRequest { + #[schema(value_type = String)] + pub visibility: Visibility, +} + +/// Log in with email + password. On success establishes a session (Set-Cookie) and +/// returns 204; on failure 401 with no detail (no user enumeration). +#[utoipa::path( + post, + path = "/api/admin/login", + request_body = LoginRequest, + responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials")) +)] +pub(crate) async fn login( + State(state): State, + session: Session, + Json(req): Json, +) -> Result { + let normalized = req.email.trim().to_lowercase(); + + let credentials = db::users::credentials_by_email(state.db.pool(), &normalized) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let verified = match &credentials { + Some((_, hash)) => auth::verify_password(&req.password, hash), + None => { + auth::verify_dummy(&req.password); + false + } + }; + + if !verified { + return Err(StatusCode::UNAUTHORIZED); + } + + let (user, _) = credentials.expect("verified implies Some"); + + auth::establish_session(&session, user.id, &user.email, user.role) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Log out: clear the session. +#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))] +pub(crate) async fn logout(session: Session) -> Result { + session + .flush() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// The current authenticated user. +#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))] +pub(crate) async fn me(user: AuthUser) -> Json { + Json(UserView { + id: user.id.to_string(), + email: user.email.as_str().to_owned(), + role: user.role.as_str().to_owned(), + }) +} + +/// List all users (Admin only). +#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))] +pub(crate) async fn list_users( + _auth: Authorized, + State(state): State, +) -> Result>, StatusCode> { + let users = db::users::list_users(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json( + users + .into_iter() + .map(|u| UserView { + id: u.id.to_string(), + email: u.email.as_str().to_owned(), + role: u.role.as_str().to_owned(), + }) + .collect(), + )) +} + +/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`. +#[utoipa::path( + post, + path = "/api/admin/objects/{id}/visibility", + params(("id" = String, Path, description = "Object id (UUID)")), + request_body = VisibilityRequest, + responses( + (status = 204, description = "Visibility changed"), + (status = 401), (status = 403), + (status = 404, description = "No such object"), + (status = 409, description = "Illegal visibility transition") + ) +)] +pub(crate) async fn set_visibility( + _auth: Authorized, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + // 404 (not 400) for an unparseable id — same non-leaking convention as the public + // surface: never reveal whether an id could exist. + let object_id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + + let mut tx = state + .db + .pool() + .begin() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // TODO(#7): record the per-user actor (AuthUser carries the id) once auth-event + // auditing lands; System for now. + let result = + db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await; + + match result { + Ok(()) => { + tx.commit() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::NO_CONTENT) + } + Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND), + Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT), + Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +/// Admin routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new() + .route("/api/admin/login", post(login)) + .route("/api/admin/logout", post(logout)) + .route("/api/admin/me", get(me)) + .route("/api/admin/users", get(list_users)) + .route("/api/admin/objects/{id}/visibility", post(set_visibility)) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index ab7c81b..aeea91b 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,11 +1,16 @@ //! HTTP API: router, handlers, and OpenAPI document. +mod admin; mod health; mod openapi; mod public; use axum::Router; use db::Db; +use time::Duration; +use tower_sessions::cookie::SameSite; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::PostgresStore; /// Shared application state passed to handlers. #[derive(Clone)] @@ -14,13 +19,34 @@ pub struct AppState { pub db: Db, /// User-facing product name (from config). Never hardcoded. pub app_name: String, + /// Whether the session cookie carries the `Secure` attribute (default true; + /// disable only for plain-HTTP self-hosting). + pub cookie_secure: bool, } /// Build the application router from shared state. pub fn build_app(state: AppState) -> Router { + let store = PostgresStore::new(state.db.pool().clone()); + + let session_layer = SessionManagerLayer::new(store) + .with_name("id") + .with_http_only(true) + .with_secure(state.cookie_secure) + .with_same_site(SameSite::Strict) + .with_expiry(Expiry::OnInactivity(Duration::hours(8))); + Router::new() .merge(health::routes()) .merge(openapi::routes()) .merge(public::routes()) + .merge(admin::routes()) + .layer(session_layer) .with_state(state) } + +/// Create the session store's table if absent. Run once at startup (and in tests +/// before exercising auth). Separate from `Db::migrate` — the session library's own +/// bookkeeping table. +pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> { + PostgresStore::new(db.pool().clone()).migrate().await +} diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index fcdc6d2..ae77ae0 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -1,16 +1,29 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; -use crate::{AppState, health, public}; +use crate::{AppState, admin, health, public}; #[derive(OpenApi)] #[openapi( - paths(health::live, health::ready, public::list_objects, public::get_object), + paths( + health::live, + health::ready, + public::list_objects, + public::get_object, + admin::login, + admin::logout, + admin::me, + admin::list_users, + admin::set_visibility + ), components(schemas( health::Live, health::Ready, public::PublicView, - public::PublicObjectPage + public::PublicObjectPage, + admin::LoginRequest, + admin::UserView, + admin::VisibilityRequest )), info(title = "Collection Management System", version = "0.0.0") )] @@ -20,7 +33,9 @@ struct ApiDoc; /// product name is never hardcoded. async fn openapi_json(State(state): State) -> Json { let mut doc = ApiDoc::openapi(); + doc.info.title = state.app_name.clone(); + Json(doc) } diff --git a/crates/api/tests/admin.rs b/crates/api/tests/admin.rs new file mode 100644 index 0000000..93efd75 --- /dev/null +++ b/crates/api/tests/admin.rs @@ -0,0 +1,336 @@ +use api::{AppState, build_app, migrate_sessions}; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use db::{catalog, users}; +use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; + +fn state(pool: PgPool) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test".into(), + cookie_secure: false, + } +} + +async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email: Email::parse(email).unwrap(), + password_hash: auth::hash_password(password).unwrap(), + role, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); +} + +fn login_request(email: &str, password: &str) -> Request { + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"email":"{email}","password":"{password}"}}"# + ))) + .unwrap() +} + +fn session_cookie(resp: &axum::http::Response) -> String { + let raw = resp + .headers() + .get(header::SET_COOKIE) + .expect("Set-Cookie") + .to_str() + .unwrap(); + + raw.split(';').next().unwrap().to_owned() +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn login_then_me_returns_identity(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await; + + let app = build_app(state(pool)); + + let resp = app + .clone() + .oneshot(login_request("admin@example.com", "s3cret-passw0rd")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let cookie = session_cookie(&resp); + + let me = app + .oneshot( + Request::builder() + .uri("/api/admin/me") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(me.status(), StatusCode::OK); + + let json: serde_json::Value = + serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(json["email"], "admin@example.com"); + assert_eq!(json["role"], "admin"); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn me_without_session_is_401(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + let app = build_app(state(pool)); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/admin/me") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn wrong_password_is_401(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "admin@example.com", "right", Role::Admin).await; + + let app = build_app(state(pool)); + + let resp = app + .oneshot(login_request("admin@example.com", "wrong")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn editor_cannot_list_users_but_admin_can(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await; + + let app = build_app(state(pool)); + + let resp = app + .clone() + .oneshot(login_request("editor@example.com", "pw-editor-123")) + .await + .unwrap(); + let editor_cookie = session_cookie(&resp); + + let listed = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/users") + .header(header::COOKIE, &editor_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::FORBIDDEN); + + let resp = app + .clone() + .oneshot(login_request("admin@example.com", "pw-admin-123")) + .await + .unwrap(); + let admin_cookie = session_cookie(&resp); + + let listed = app + .oneshot( + Request::builder() + .uri("/api/admin/users") + .header(header::COOKIE, &admin_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn editor_can_publish_via_admin_endpoint(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &ObjectInput { + object_number: "P-1".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Internal, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + + let resp = app + .clone() + .oneshot(login_request("editor@example.com", "pw-editor-123")) + .await + .unwrap(); + let cookie = session_cookie(&resp); + + let publish = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/admin/objects/{id}/visibility")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"visibility":"public"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(publish.status(), StatusCode::NO_CONTENT); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Public); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn logout_invalidates_the_session(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await; + let app = build_app(state(pool)); + + let resp = app + .clone() + .oneshot(login_request("admin@example.com", "s3cret-passw0rd")) + .await + .unwrap(); + let cookie = session_cookie(&resp); + + // logout with the session cookie + let out = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/logout") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(out.status(), StatusCode::NO_CONTENT); + + // the old cookie no longer authenticates + let me = app + .oneshot( + Request::builder() + .uri("/api/admin/me") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(me.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn illegal_visibility_transition_is_409(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + + // a draft object — draft -> public in one step is illegal (must pass through internal) + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &ObjectInput { + object_number: "D-1".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Draft, + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + let resp = app + .clone() + .oneshot(login_request("editor@example.com", "pw-editor-123")) + .await + .unwrap(); + let cookie = session_cookie(&resp); + + let publish = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/admin/objects/{id}/visibility")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"visibility":"public"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(publish.status(), StatusCode::CONFLICT); +} diff --git a/crates/api/tests/health.rs b/crates/api/tests/health.rs index da40fe1..46cb9a7 100644 --- a/crates/api/tests/health.rs +++ b/crates/api/tests/health.rs @@ -9,6 +9,7 @@ fn state(pool: PgPool, app_name: &str) -> AppState { AppState { db: db::Db::from_pool(pool), app_name: app_name.to_string(), + cookie_secure: false, } } diff --git a/crates/api/tests/public.rs b/crates/api/tests/public.rs index a3a3c0e..d30e91b 100644 --- a/crates/api/tests/public.rs +++ b/crates/api/tests/public.rs @@ -11,6 +11,7 @@ fn state(pool: PgPool) -> AppState { AppState { db: db::Db::from_pool(pool), app_name: "Test".to_string(), + cookie_secure: false, } } diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml new file mode 100644 index 0000000..6f791a1 --- /dev/null +++ b/crates/auth/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "auth" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +axum.workspace = true +domain = { path = "../domain" } +argon2.workspace = true +tower-sessions.workspace = true +serde.workspace = true +uuid.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs new file mode 100644 index 0000000..0a0466a --- /dev/null +++ b/crates/auth/src/lib.rs @@ -0,0 +1,243 @@ +//! Authentication & authorization: argon2id password hashing and the type-driven +//! axum extractors that gate handlers. Identity is read from the session (set at +//! login); these extractors do not touch the database. + +use std::marker::PhantomData; +use std::sync::OnceLock; + +use argon2::Argon2; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use axum::extract::FromRequestParts; +use axum::http::StatusCode; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; +use domain::{Capability, Email, Role, UserId}; +use tower_sessions::Session; + +const SESSION_USER_ID: &str = "user_id"; +const SESSION_EMAIL: &str = "email"; +const SESSION_ROLE: &str = "role"; + +/// Hash a plaintext password as an argon2id PHC string. +pub fn hash_password(plaintext: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + Ok(Argon2::default() + .hash_password(plaintext.as_bytes(), &salt)? + .to_string()) +} + +/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a +/// wrong password OR a malformed/unparseable hash (never errors out). +pub fn verify_password(plaintext: &str, phc: &str) -> bool { + let Ok(parsed) = PasswordHash::new(phc) else { + return false; + }; + + Argon2::default() + .verify_password(plaintext.as_bytes(), &parsed) + .is_ok() +} + +/// Spend a verify's worth of time against a fixed dummy hash. Call this on the +/// "user not found" login path to blunt user-enumeration via response timing. +pub fn verify_dummy(plaintext: &str) { + static DUMMY: OnceLock = OnceLock::new(); + let hash = + DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy")); + + let _ = verify_password(plaintext, hash); +} + +/// Record the authenticated identity into the session (call after a successful +/// password check). Cycles the session id first to prevent session fixation. +pub async fn establish_session( + session: &Session, + id: UserId, + email: &Email, + role: Role, +) -> Result<(), tower_sessions::session::Error> { + session.cycle_id().await?; + session.insert(SESSION_USER_ID, id.to_uuid()).await?; + session.insert(SESSION_EMAIL, email.as_str()).await?; + session.insert(SESSION_ROLE, role.as_str()).await?; + + Ok(()) +} + +/// Rejection for the auth extractors. +#[derive(Debug, Clone, Copy, thiserror::Error)] +pub enum AuthError { + #[error("authentication required")] + Unauthenticated, + #[error("insufficient permissions")] + Forbidden, + /// The session store itself failed (e.g. the database is unreachable) — distinct + /// from "no session", so an outage surfaces as 500 rather than a misleading 401. + #[error("session store unavailable")] + Internal, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + AuthError::Unauthenticated => StatusCode::UNAUTHORIZED, + AuthError::Forbidden => StatusCode::FORBIDDEN, + AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + .into_response() + } +} + +/// The authenticated user, reconstructed from the session. Extracting this proves +/// the request carries a valid session (else `401`). +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: UserId, + pub email: Email, + pub role: Role, +} + +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // A failed extraction here means the SessionManagerLayer is missing from the + // stack — a wiring bug, not an auth failure: surface it as 500. + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AuthError::Internal)?; + + // For each key: a store error (DB down) is `Internal` (500); an absent key is + // `Unauthenticated` (401) — these must not be conflated. + let id: uuid::Uuid = session + .get(SESSION_USER_ID) + .await + .map_err(|_| AuthError::Internal)? + .ok_or(AuthError::Unauthenticated)?; + + let email: String = session + .get(SESSION_EMAIL) + .await + .map_err(|_| AuthError::Internal)? + .ok_or(AuthError::Unauthenticated)?; + + let role_str: String = session + .get(SESSION_ROLE) + .await + .map_err(|_| AuthError::Internal)? + .ok_or(AuthError::Unauthenticated)?; + + let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?; + + Ok(AuthUser { + id: UserId::from_uuid(id), + email: Email::from_db(email), + role, + }) + } +} + +/// A zero-sized type naming a required [`Capability`]. Implementors are used as the +/// type parameter of [`Authorized`]. +pub trait CapabilityMarker { + const CAP: Capability; +} + +/// Require `ManageUsers`. +pub struct ManageUsers; + +impl CapabilityMarker for ManageUsers { + const CAP: Capability = Capability::ManageUsers; +} + +/// Require `EditCatalogue`. +pub struct EditCatalogue; + +impl CapabilityMarker for EditCatalogue { + const CAP: Capability = Capability::EditCatalogue; +} + +/// Require `PublishObjects`. +pub struct PublishObjects; + +impl CapabilityMarker for PublishObjects { + const CAP: Capability = Capability::PublishObjects; +} + +/// Require `ViewInternal`. +pub struct ViewInternal; + +impl CapabilityMarker for ViewInternal { + const CAP: Capability = Capability::ViewInternal; +} + +/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized` +/// cannot run without the request's role allowing `C` (else `403`). +#[derive(Debug, Clone)] +pub struct Authorized { + pub user: AuthUser, + _capability: PhantomData, +} + +impl FromRequestParts for Authorized +where + S: Send + Sync, + C: CapabilityMarker, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = AuthUser::from_request_parts(parts, state).await?; + + if user.role.allows(C::CAP) { + Ok(Authorized { + user, + _capability: PhantomData, + }) + } else { + Err(AuthError::Forbidden) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify_round_trips() { + let hash = hash_password("correct horse battery staple").unwrap(); + assert!(hash.starts_with("$argon2id$")); + assert!(verify_password("correct horse battery staple", &hash)); + } + + #[test] + fn verify_rejects_wrong_password() { + let hash = hash_password("right").unwrap(); + assert!(!verify_password("wrong", &hash)); + } + + #[test] + fn verify_rejects_malformed_hash() { + assert!(!verify_password("anything", "not-a-phc-string")); + } + + #[test] + fn verify_dummy_does_not_panic() { + verify_dummy("any input"); + verify_dummy("called again"); // exercises the already-initialized OnceLock path + } + + #[test] + fn capability_markers_map_to_domain_capabilities() { + assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers); + assert_eq!(EditCatalogue::CAP, domain::Capability::EditCatalogue); + assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects); + assert_eq!(ViewInternal::CAP, domain::Capability::ViewInternal); + } +} diff --git a/crates/db/migrations/0006_users.sql b/crates/db/migrations/0006_users.sql new file mode 100644 index 0000000..b6efc36 --- /dev/null +++ b/crates/db/migrations/0006_users.sql @@ -0,0 +1,19 @@ +-- Users of this organization's instance. One database == one organization, so no +-- org_id. Passwords are stored only as argon2id PHC strings. +-- +-- `updated_at` is maintained manually in UPDATE statements (as in the object table); +-- there is no auto-update trigger and no update path exists yet. +CREATE TABLE app_user ( + id UUID PRIMARY KEY, + email TEXT NOT NULL CHECK (email <> ''), + password_hash TEXT NOT NULL CHECK (password_hash <> ''), + role TEXT NOT NULL CHECK (role IN ('admin', 'editor')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Case-insensitive uniqueness on email, enforced at the database. The application +-- stores normalized (lowercased) emails and looks up via `lower(email) = $1`, so this +-- functional unique index both backs those lookups and guarantees no case-variant +-- duplicate can exist even if a non-normalized value were ever written. +CREATE UNIQUE INDEX app_user_email_lower_key ON app_user (lower(email)); diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 403e041..17267b8 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -5,6 +5,7 @@ pub mod authority; pub mod catalog; pub mod fields; pub mod seed; +pub mod users; pub mod vocab; use sqlx::postgres::{PgPool, PgPoolOptions}; diff --git a/crates/db/src/users.rs b/crates/db/src/users.rs new file mode 100644 index 0000000..0016577 --- /dev/null +++ b/crates/db/src/users.rs @@ -0,0 +1,123 @@ +//! Users of this organization's instance. All SQL for users lives here. + +use domain::{ + AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId, +}; +use serde_json::json; +use sqlx::Row; + +use crate::audit; + +const ENTITY_TYPE: &str = "user"; + +const USER_COLUMNS: &str = "id, email, role"; + +/// Create a user and record a `created` audit entry (email + role only — never the +/// password hash), both on `conn`. Pass a transaction connection. +pub async fn create_user( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + new: &NewUser, +) -> Result { + let id = UserId::new(); + + sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)") + .bind(id.to_uuid()) + .bind(new.email.as_str()) + .bind(&new.password_hash) + .bind(new.role.as_str()) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: vec![ + FieldChange { + field: "email".to_owned(), + before: None, + after: Some(json!(new.email.as_str())), + }, + FieldChange { + field: "role".to_owned(), + before: None, + after: Some(json!(new.role.as_str())), + }, + ], + }, + ) + .await?; + + Ok(id) +} + +/// Fetch a user by id. +pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1"); + + let row = sqlx::query(&sql) + .bind(id.to_uuid()) + .fetch_optional(executor) + .await?; + + row.map(map_user).transpose() +} + +/// Fetch a user and their password hash by (normalized) email, for login. +pub async fn credentials_by_email<'e, E>( + executor: E, + email: &str, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + // Match the `lower(email)` unique index; `email` is already normalized by callers. + let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE lower(email) = $1"); + + let row = sqlx::query(&sql) + .bind(email) + .fetch_optional(executor) + .await?; + + match row { + Some(row) => { + let hash: String = row.try_get("password_hash")?; + + Ok(Some((map_user(row)?, hash))) + } + None => Ok(None), + } +} + +/// List all users, ordered by email. +// TODO: add LIMIT/keyset pagination before exposing this via the API. +pub async fn list_users<'e, E>(executor: E) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email"); + + let rows = sqlx::query(&sql).fetch_all(executor).await?; + + rows.into_iter().map(map_user).collect() +} + +fn map_user(row: sqlx::postgres::PgRow) -> Result { + let role_str: String = row.try_get("role")?; + + let role = Role::from_db(&role_str) + .ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?; + + Ok(User { + id: UserId::from_uuid(row.try_get("id")?), + email: Email::from_db(row.try_get("email")?), + role, + }) +} diff --git a/crates/db/tests/users.rs b/crates/db/tests/users.rs new file mode 100644 index 0000000..11dc0ff --- /dev/null +++ b/crates/db/tests/users.rs @@ -0,0 +1,126 @@ +use db::{Db, audit, users}; +use domain::{AuditAction, AuditActor, Email, NewUser, Role}; +use sqlx::PgPool; + +fn new_user(email: &str, role: Role) -> NewUser { + NewUser { + email: Email::parse(email).unwrap(), + password_hash: "$argon2id$dummy".to_owned(), + role, + } +} + +#[sqlx::test] +async fn create_then_fetch_by_id_and_email(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Admin), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let user = users::user_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(user.email.as_str(), "anna@example.com"); + assert_eq!(user.role, Role::Admin); + + let (by_email, hash) = users::credentials_by_email(db.pool(), "anna@example.com") + .await + .unwrap() + .unwrap(); + assert_eq!(by_email.id, id); + assert_eq!(hash, "$argon2id$dummy"); +} + +#[sqlx::test] +async fn create_user_audits_email_and_role_but_never_the_hash(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Editor), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let history = audit::history_for(db.pool(), "user", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, AuditAction::Created); + let mut fields: Vec<&str> = history[0] + .changes + .iter() + .map(|c| c.field.as_str()) + .collect(); + fields.sort_unstable(); + assert_eq!(fields, vec!["email", "role"]); +} + +#[sqlx::test] +async fn missing_email_returns_none(pool: PgPool) { + let db = Db::from_pool(pool); + assert!( + users::credentials_by_email(db.pool(), "nobody@example.com") + .await + .unwrap() + .is_none() + ); +} + +#[sqlx::test] +async fn list_users_is_ordered_by_email(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &new_user("zoe@example.com", Role::Editor), + ) + .await + .unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &new_user("amy@example.com", Role::Admin), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let users = users::list_users(db.pool()).await.unwrap(); + let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect(); + assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]); +} + +#[sqlx::test] +async fn duplicate_email_is_rejected(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Admin), + ) + .await + .unwrap(); + // Same normalized email again — the lower(email) unique index must reject it. + let err = users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Editor), + ) + .await + .unwrap_err(); + assert!( + matches!(err, sqlx::Error::Database(_)), + "expected a unique-violation database error, got {err:?}" + ); +} diff --git a/crates/domain/src/id.rs b/crates/domain/src/id.rs index 6f7be8d..b3b4f4b 100644 --- a/crates/domain/src/id.rs +++ b/crates/domain/src/id.rs @@ -72,6 +72,10 @@ id_newtype!( /// Identifier for a flexible-field definition. FieldDefinitionId ); +id_newtype!( + /// Identifier for a user of this organization's instance. + UserId +); #[cfg(test)] mod tests { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index e3a4dbe..fa3f759 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -6,12 +6,14 @@ mod field_definition; mod id; mod label; mod object; +mod user; mod vocabulary; pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent}; pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority}; pub use field_definition::{FieldDefinition, FieldType, NewFieldDefinition}; -pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, VocabularyId}; +pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId}; pub use label::{LocalizedLabel, pick_label}; pub use object::{CatalogueObject, IllegalTransition, ObjectInput, Visibility}; +pub use user::{Capability, Email, EmailError, NewUser, Role, User}; pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary}; diff --git a/crates/domain/src/user.rs b/crates/domain/src/user.rs new file mode 100644 index 0000000..a20bfaf --- /dev/null +++ b/crates/domain/src/user.rs @@ -0,0 +1,188 @@ +//! User identity, roles, and the capability policy. +//! +//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The +//! role→capability mapping (`Role::allows`) is the single source of authorization +//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth` +//! boundary, never in these types. + +use serde::{Deserialize, Serialize}; + +use crate::UserId; + +/// A validated email address (normalized to lowercase, trimmed). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Email(String); + +/// The supplied string is not a syntactically acceptable email. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmailError; + +impl std::fmt::Display for EmailError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid email address") + } +} + +impl std::error::Error for EmailError {} + +impl Email { + /// Parse and normalize an email. Light MVP validation: a single `@`, non-empty + /// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321 + /// validation is deferred.) + pub fn parse(raw: &str) -> Result { + let normalized = raw.trim().to_lowercase(); + + if normalized.contains(char::is_whitespace) { + return Err(EmailError); + } + + let mut parts = normalized.split('@'); + let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else { + return Err(EmailError); + }; + + let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'); + + if local.is_empty() || !domain_ok { + return Err(EmailError); + } + + Ok(Email(normalized)) + } + + /// The normalized string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Reconstruct from a stored (already-validated) value, without re-validating. + /// For reading values back from the database only — never to construct an `Email` + /// destined to be written (writes must go through [`Email::parse`] so storage + /// stays normalized). + pub fn from_db(value: String) -> Email { + Email(value) + } +} + +/// A user's role within the organization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + /// Full access, including user management. + Admin, + /// Catalogue work: create/edit/publish records; cannot manage users. + Editor, +} + +impl Role { + pub const fn as_str(&self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Editor => "editor", + } + } + + pub fn from_db(s: &str) -> Option { + match s { + "admin" => Some(Role::Admin), + "editor" => Some(Role::Editor), + _ => None, + } + } + + /// The authorization policy: whether this role may perform `capability`. + /// + /// The `Editor` arm is an exhaustive `match` on purpose: adding a new + /// [`Capability`] variant is a compile error here until its Editor access is + /// decided explicitly, so the policy fails closed rather than silently granting + /// new capabilities to Editors. + pub fn allows(self, capability: Capability) -> bool { + match self { + Role::Admin => true, + Role::Editor => match capability { + Capability::EditCatalogue + | Capability::PublishObjects + | Capability::ViewInternal => true, + Capability::ManageUsers => false, + }, + } + } +} + +/// A guarded action. `Authorized` (in the `auth` crate) gates a handler on one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Capability { + /// Create/list/modify users. + ManageUsers, + /// Create and edit catalogue records. + EditCatalogue, + /// Change a record's visibility (publish/unpublish). + PublishObjects, + /// Read internal (non-public) records. + ViewInternal, +} + +/// A user as read back from storage. Carries no password material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + pub id: UserId, + pub email: Email, + pub role: Role, +} + +/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`). +#[derive(Debug, Clone)] +pub struct NewUser { + pub email: Email, + pub password_hash: String, + pub role: Role, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_parses_and_normalizes() { + assert_eq!( + Email::parse(" Anna@Example.COM ").unwrap().as_str(), + "anna@example.com" + ); + } + + #[test] + fn email_rejects_garbage() { + for bad in [ + "", + "no-at", + "a@b", + "a@@b.com", + "a b@c.com", + "@example.com", + "x@.com", + "x@com.", + ] { + assert!(Email::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn role_round_trips() { + for r in [Role::Admin, Role::Editor] { + assert_eq!(Role::from_db(r.as_str()), Some(r)); + } + assert_eq!(Role::from_db("superuser"), None); + } + + #[test] + fn capability_policy_matrix() { + use Capability::*; + for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] { + assert!(Role::Admin.allows(cap)); + } + assert!(!Role::Editor.allows(ManageUsers)); + for cap in [EditCatalogue, PublishObjects, ViewInternal] { + assert!(Role::Editor.allows(cap)); + } + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index c3f9888..08bb5b3 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -19,12 +19,17 @@ anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true api = { path = "../api" } +auth = { path = "../auth" } db = { path = "../db" } +domain = { path = "../domain" } +rpassword.workspace = true [dev-dependencies] reqwest.workspace = true serde_json.workspace = true api = { path = "../api" } +auth = { path = "../auth" } db = { path = "../db" } +domain = { path = "../domain" } sqlx.workspace = true temp-env = "0.3" diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index a1bb6ea..458f2be 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -18,4 +18,13 @@ pub struct Config { /// time. The product name must never be hardcoded in source. #[arg(long, env = "APP_NAME", default_value = "Collection Management System")] pub app_name: String, + + /// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable + /// only for plain-HTTP self-hosting behind no TLS at all. + #[arg( + long = "session-cookie-secure", + env = "SESSION_COOKIE_SECURE", + default_value_t = true + )] + pub cookie_secure: bool, } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index cad5f8d..0d5c3dd 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -5,8 +5,9 @@ mod config; pub use config::Config; use anyhow::Context; -use api::{AppState, build_app}; +use api::{AppState, build_app, migrate_sessions}; use db::Db; +use domain::{AuditActor, Email, NewUser, Role}; use tokio::net::TcpListener; /// Connect dependencies from `config` and serve until shutdown. @@ -17,14 +18,20 @@ pub async fn run(config: Config) -> anyhow::Result<()> { db.migrate().await.context("running database migrations")?; + migrate_sessions(&db) + .await + .context("creating the session store")?; + let state = AppState { db, app_name: config.app_name.clone(), + cookie_secure: config.cookie_secure, }; let listener = TcpListener::bind(&config.bind_addr) .await .with_context(|| format!("binding to {}", config.bind_addr))?; + tracing::info!(addr = %config.bind_addr, "server listening"); serve(listener, state).await @@ -33,8 +40,56 @@ pub async fn run(config: Config) -> anyhow::Result<()> { /// Serve the API on an already-bound listener (used by `run` and tests). pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { let app = build_app(state); + axum::serve(listener, app) .await .context("running the HTTP server")?; + + Ok(()) +} + +/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI +/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set, +/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is +/// confined to the scope below and dropped before any network I/O. +pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> { + let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?; + + // Read, validate, and hash the password in a scope so the plaintext `String` is + // dropped before we open a connection / run any awaits. + let password_hash = { + let password = match std::env::var("BOOTSTRAP_PASSWORD") { + Ok(p) => p, + Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, + }; + anyhow::ensure!( + password.chars().count() >= 8, + "password must be at least 8 characters" + ); + auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))? + }; + + let db = Db::connect(database_url) + .await + .context("connecting to the database")?; + + let mut tx = db.pool().begin().await?; + + let id = db::users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email, + password_hash, + role, + }, + ) + .await + .context("creating the user (is the email already taken?)")?; + + tx.commit().await?; + + println!("created user {id} ({role:?})"); + Ok(()) } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 8c94771..69c36eb 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,5 +1,41 @@ -use clap::Parser; -use server::{Config, run}; +use clap::{Parser, Subcommand, ValueEnum}; +use domain::Role; +use server::{Config, create_user, run}; + +#[derive(Parser)] +#[command(version, about = "Collection management system server")] +struct Cli { + #[command(subcommand)] + command: Option, + #[command(flatten)] + config: Config, +} + +#[derive(Subcommand)] +enum Command { + /// Create a user (admin bootstrap). + CreateUser { + #[arg(long)] + email: String, + #[arg(long, value_enum)] + role: RoleArg, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum RoleArg { + Admin, + Editor, +} + +impl From for Role { + fn from(r: RoleArg) -> Self { + match r { + RoleArg::Admin => Role::Admin, + RoleArg::Editor => Role::Editor, + } + } +} #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -7,6 +43,12 @@ async fn main() -> anyhow::Result<()> { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let config = Config::parse(); - run(config).await + let cli = Cli::parse(); + + match cli.command { + None => run(cli.config).await, + Some(Command::CreateUser { email, role }) => { + create_user(&cli.config.database_url, &email, role.into()).await + } + } } diff --git a/crates/server/tests/config.rs b/crates/server/tests/config.rs index ee524e9..cb0501b 100644 --- a/crates/server/tests/config.rs +++ b/crates/server/tests/config.rs @@ -1,10 +1,11 @@ use clap::Parser; use server::Config; -const CLEARED: [(&str, Option<&str>); 3] = [ +const CLEARED: [(&str, Option<&str>); 4] = [ ("DATABASE_URL", None), ("BIND_ADDR", None), ("APP_NAME", None), + ("SESSION_COOKIE_SECURE", None), ]; #[test] @@ -25,3 +26,11 @@ fn database_url_is_required() { assert!(Config::try_parse_from(["server"]).is_err()); }); } + +#[test] +fn cookie_secure_defaults_to_true() { + temp_env::with_vars(CLEARED, || { + let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap(); + assert!(config.cookie_secure); + }); +} diff --git a/crates/server/tests/create_user.rs b/crates/server/tests/create_user.rs new file mode 100644 index 0000000..a3df21f --- /dev/null +++ b/crates/server/tests/create_user.rs @@ -0,0 +1,50 @@ +use db::Db; +use domain::Role; +use sqlx::PgPool; + +// Note: `server::create_user` opens its own DB connection by URL, but `#[sqlx::test]` +// provisions a temporary database whose URL is not directly exposed. The test below +// exercises the same building blocks that `server::create_user` composes — +// `auth::hash_password` + `db::users::create_user` + `db::users::credentials_by_email` — +// against the test pool, which fully validates the end-to-end bootstrap logic. + +#[sqlx::test(migrations = "../db/migrations")] +async fn create_user_persists_and_password_verifies(pool: PgPool) { + let db = Db::from_pool(pool.clone()); + + let hash = auth::hash_password("bootstrap-pw-123").unwrap(); + + let mut tx = db.pool().begin().await.unwrap(); + + db::users::create_user( + &mut tx, + domain::AuditActor::System, + &domain::NewUser { + email: domain::Email::parse("boss@example.com").unwrap(), + password_hash: hash, + role: Role::Admin, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + let (user, stored_hash) = db::users::credentials_by_email(db.pool(), "boss@example.com") + .await + .unwrap() + .unwrap(); + + assert_eq!(user.role, Role::Admin); + assert!(auth::verify_password("bootstrap-pw-123", &stored_hash)); +} + +#[tokio::test] +async fn create_user_rejects_invalid_email() { + // The email is parsed before the password is read or the DB is touched, so an + // invalid email errors out without reaching the (unreachable) database URL. + let err = server::create_user("postgres://unused", "not-an-email", Role::Admin) + .await + .unwrap_err(); + assert!(err.to_string().contains("email"), "got: {err}"); +} diff --git a/crates/server/tests/serve.rs b/crates/server/tests/serve.rs index cdad388..c0c49cd 100644 --- a/crates/server/tests/serve.rs +++ b/crates/server/tests/serve.rs @@ -15,6 +15,7 @@ async fn serves_health_live_over_tcp() { let state = AppState { db, app_name: "Test".to_string(), + cookie_secure: false, }; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/docs/plans/2026-06-02-auth-email-password.md b/docs/plans/2026-06-02-auth-email-password.md new file mode 100644 index 0000000..bce0635 --- /dev/null +++ b/docs/plans/2026-06-02-auth-email-password.md @@ -0,0 +1,1345 @@ +# Authentication (email/password) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Email/password authentication scoped to the single org the instance serves, with server-side sessions and a type-driven authorization layer: an `auth` crate providing argon2id password hashing + axum extractors (`AuthUser`, `Authorized`), a `users` table + repository, an admin login/logout/me surface, the first capability-gated admin endpoints (list users, publish object), and a `create-user` CLI for bootstrapping the first admin. **OIDC is a separate later plan.** + +**Architecture:** Sessions via **`tower-sessions`** (opaque session id in an httpOnly + SameSite=Strict cookie) backed by **`tower-sessions-sqlx-store`**'s `PostgresStore` in the org DB. Authorization is type-driven: `AuthUser` is reconstructed from the session (no per-request DB hit); `Authorized` takes a zero-sized capability marker so a privileged handler cannot compile without naming the capability it requires. Role policy (`Role::allows`) is pure domain logic. Dependency direction: `auth → domain` (auth does **not** depend on `db`); `api → auth, db, domain`; `server → api, auth, db, domain`. + +**Tech Stack:** Rust 2024, axum 0.8, sqlx 0.8, `argon2 = "0.5"` (argon2id), `tower-sessions = "0.14"` + `tower-sessions-sqlx-store = "0.15"` (features `["postgres"]`; this pair both resolve `tower-sessions-core` 0.14 — do **not** bump `tower-sessions` to 0.15, the store hasn't caught up), `rpassword = "7"` (CLI password prompt). Tests: `#[sqlx::test]` + axum `oneshot`. + +## Design decisions (approved) +- Email/password now; **OIDC deferred** to its own plan. +- **Sessions via `tower-sessions` + Postgres store** (server-side, revocable, no Redis); httpOnly + Secure + SameSite=Strict cookie; `Secure` is a config flag (default true) so plain-HTTP self-hosters can disable it. +- Roles **Admin / Editor** only (RBAC mapped to a `Capability` enum); Admin = all, Editor = all except `ManageUsers`. +- First admin via **`server create-user` CLI** (hidden `rpassword` prompt, or `BOOTSTRAP_PASSWORD` env); no public self-registration. +- Protected endpoints this plan: `GET /api/admin/me` (any `AuthUser`), `GET /api/admin/users` (`Authorized`, Admin-only), and `POST /api/admin/objects/{id}/visibility` (`Authorized`, reusing Plan 7's `set_visibility` — closes issue #15). + +## Prerequisites +- Postgres for tests; pass `DATABASE_URL` inline. Pass transaction connections as `&mut tx`. +- `cargo +nightly fmt` (nightly). Clean clippy `--all-targets -- -D warnings`. +- The codename "biggus"/"dickus" must appear nowhere in code/comments/identifiers. + +## Workspace dependency additions +Add to root `Cargo.toml` `[workspace.dependencies]` (verify latest patch via cratesio if desired; majors are pinned by the compatibility analysis above): +```toml +argon2 = "0.5" +tower-sessions = "0.14" +tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } +rpassword = "7" +``` + +## File Structure +``` +Cargo.toml + auth member + 4 workspace deps above +crates/domain/src/id.rs + UserId +crates/domain/src/user.rs (new) Email, Role, Capability, User, NewUser +crates/domain/src/lib.rs + mod user; exports +crates/db/migrations/0006_users.sql (new) app_user table +crates/db/src/users.rs (new) create_user, user_by_id, credentials_by_email, list_users +crates/db/src/lib.rs + pub mod users; +crates/db/tests/users.rs (new) +crates/auth/Cargo.toml (new) +crates/auth/src/lib.rs (new) password hashing + extractors + capability markers +crates/api/Cargo.toml + auth, tower-sessions, tower-sessions-sqlx-store +crates/api/src/admin.rs (new) login/logout/me/users/visibility handlers +crates/api/src/lib.rs + mod admin; session layer in build_app; AppState.cookie_secure; migrate_sessions +crates/api/src/openapi.rs + register admin paths + schemas +crates/api/tests/admin.rs (new) +crates/api/tests/health.rs (modify) AppState constructor gains cookie_secure +crates/api/tests/public.rs (modify) AppState constructor gains cookie_secure +crates/server/Cargo.toml + auth, domain, tower-sessions-sqlx-store, rpassword +crates/server/src/config.rs + cookie_secure field +crates/server/src/lib.rs + migrate_sessions on startup; create_user fn; AppState.cookie_secure +crates/server/src/main.rs + Cli (subcommands): default serve, create-user +crates/server/tests/config.rs (modify) cookie_secure default +``` + +--- + +## Task 1: `domain` — user identity, role & capability policy + +**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/user.rs`. + +- [ ] **Step 1: Add the `UserId` newtype.** In `crates/domain/src/id.rs`, add another `id_newtype!` invocation alongside the others: +```rust +id_newtype!( + /// Identifier for a user of this organization's instance. + UserId +); +``` + +- [ ] **Step 2: Write the failing tests** — create `crates/domain/src/user.rs` with the types and a test module: +```rust +//! User identity, roles, and the capability policy. +//! +//! `Role` is persisted; `Capability` is the vocabulary of guarded actions. The +//! role→capability mapping (`Role::allows`) is the single source of authorization +//! policy — pure and unit-tested. Password hashes live only at the `db`/`auth` +//! boundary, never in these types. + +use serde::{Deserialize, Serialize}; + +use crate::UserId; + +/// A validated email address (normalized to lowercase, trimmed). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Email(String); + +/// The supplied string is not a syntactically acceptable email. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmailError; + +impl std::fmt::Display for EmailError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid email address") + } +} + +impl std::error::Error for EmailError {} + +impl Email { + /// Parse and normalize an email. Light MVP validation: a single `@`, non-empty + /// local part, a dotted non-edge domain, and no whitespace. (Fuller RFC 5321 + /// validation is deferred.) + pub fn parse(raw: &str) -> Result { + let normalized = raw.trim().to_lowercase(); + if normalized.contains(char::is_whitespace) { + return Err(EmailError); + } + let mut parts = normalized.split('@'); + let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else { + return Err(EmailError); + }; + let domain_ok = domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'); + if local.is_empty() || !domain_ok { + return Err(EmailError); + } + Ok(Email(normalized)) + } + + /// The normalized string. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Reconstruct from a stored (already-validated) value, without re-validating. + pub fn from_db(value: String) -> Email { + Email(value) + } +} + +/// A user's role within the organization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + /// Full access, including user management. + Admin, + /// Catalogue work: create/edit/publish records; cannot manage users. + Editor, +} + +impl Role { + pub const fn as_str(&self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Editor => "editor", + } + } + + pub fn from_db(s: &str) -> Option { + match s { + "admin" => Some(Role::Admin), + "editor" => Some(Role::Editor), + _ => None, + } + } + + /// The authorization policy: whether this role may perform `capability`. + pub fn allows(self, capability: Capability) -> bool { + match self { + Role::Admin => true, + Role::Editor => !matches!(capability, Capability::ManageUsers), + } + } +} + +/// A guarded action. `Authorized` (in the `auth` crate) gates a handler on one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Capability { + /// Create/list/modify users. + ManageUsers, + /// Create and edit catalogue records. + EditCatalogue, + /// Change a record's visibility (publish/unpublish). + PublishObjects, + /// Read internal (non-public) records. + ViewInternal, +} + +/// A user as read back from storage. Carries no password material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + pub id: UserId, + pub email: Email, + pub role: Role, +} + +/// A new user to persist. `password_hash` is an argon2id PHC string (produced by `auth`). +#[derive(Debug, Clone)] +pub struct NewUser { + pub email: Email, + pub password_hash: String, + pub role: Role, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_parses_and_normalizes() { + assert_eq!(Email::parse(" Anna@Example.COM ").unwrap().as_str(), "anna@example.com"); + } + + #[test] + fn email_rejects_garbage() { + for bad in ["", "no-at", "a@b", "a@@b.com", "a b@c.com", "@example.com", "x@.com", "x@com."] { + assert!(Email::parse(bad).is_err(), "should reject {bad:?}"); + } + } + + #[test] + fn role_round_trips() { + for r in [Role::Admin, Role::Editor] { + assert_eq!(Role::from_db(r.as_str()), Some(r)); + } + assert_eq!(Role::from_db("superuser"), None); + } + + #[test] + fn capability_policy_matrix() { + use Capability::*; + // Admin can do everything. + for cap in [ManageUsers, EditCatalogue, PublishObjects, ViewInternal] { + assert!(Role::Admin.allows(cap)); + } + // Editor can do everything except manage users. + assert!(!Role::Editor.allows(ManageUsers)); + for cap in [EditCatalogue, PublishObjects, ViewInternal] { + assert!(Role::Editor.allows(cap)); + } + } +} +``` + +- [ ] **Step 3: Run to verify the tests fail / module is wired.** Add to `crates/domain/src/lib.rs`: `mod user;` (with the others) and extend exports: +```rust +pub use id::{AuthorityId, FieldDefinitionId, ObjectId, OrgId, TermId, UserId, VocabularyId}; +pub use user::{Capability, Email, EmailError, NewUser, Role, User}; +``` + +- [ ] **Step 4: Run to verify it passes.** `cargo test -p domain` → PASS (new email/role/capability tests + existing). + +- [ ] **Step 5: Lint.** `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean. + +- [ ] **Step 6: Commit.** +```bash +git add crates/domain +git commit -m "feat(domain): user identity (UserId, Email), Role/Capability policy" +``` + +--- + +## Task 2: `db` — users table + repository + +**Files:** create `crates/db/migrations/0006_users.sql`, `crates/db/src/users.rs`, `crates/db/tests/users.rs`; modify `crates/db/src/lib.rs`. + +- [ ] **Step 1: Migration** `crates/db/migrations/0006_users.sql`: +```sql +-- Users of this organization's instance. One database == one organization, so no +-- org_id. Email is stored already-normalized (lowercase) by the application, so a +-- plain UNIQUE suffices. Passwords are stored only as argon2id PHC strings. +CREATE TABLE app_user ( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE CHECK (email <> ''), + password_hash TEXT NOT NULL CHECK (password_hash <> ''), + role TEXT NOT NULL CHECK (role IN ('admin', 'editor')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +- [ ] **Step 2: Write the failing tests** `crates/db/tests/users.rs`: +```rust +use db::{Db, audit, users}; +use domain::{AuditAction, AuditActor, Email, NewUser, Role}; +use sqlx::PgPool; + +fn new_user(email: &str, role: Role) -> NewUser { + NewUser { + email: Email::parse(email).unwrap(), + password_hash: "$argon2id$dummy".to_owned(), + role, + } +} + +#[sqlx::test] +async fn create_then_fetch_by_id_and_email(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = users::create_user(&mut tx, AuditActor::System, &new_user("anna@example.com", Role::Admin)) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let user = users::user_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(user.email.as_str(), "anna@example.com"); + assert_eq!(user.role, Role::Admin); + + let (by_email, hash) = users::credentials_by_email(db.pool(), "anna@example.com") + .await + .unwrap() + .unwrap(); + assert_eq!(by_email.id, id); + assert_eq!(hash, "$argon2id$dummy"); +} + +#[sqlx::test] +async fn create_user_audits_email_and_role_but_never_the_hash(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = users::create_user(&mut tx, AuditActor::System, &new_user("anna@example.com", Role::Editor)) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let history = audit::history_for(db.pool(), "user", id.to_uuid()).await.unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, AuditAction::Created); + let mut fields: Vec<&str> = history[0].changes.iter().map(|c| c.field.as_str()).collect(); + fields.sort_unstable(); + assert_eq!(fields, vec!["email", "role"]); // password_hash must NOT appear +} + +#[sqlx::test] +async fn missing_email_returns_none(pool: PgPool) { + let db = Db::from_pool(pool); + assert!(users::credentials_by_email(db.pool(), "nobody@example.com").await.unwrap().is_none()); +} + +#[sqlx::test] +async fn list_users_is_ordered_by_email(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + users::create_user(&mut tx, AuditActor::System, &new_user("zoe@example.com", Role::Editor)).await.unwrap(); + users::create_user(&mut tx, AuditActor::System, &new_user("amy@example.com", Role::Admin)).await.unwrap(); + tx.commit().await.unwrap(); + + let users = users::list_users(db.pool()).await.unwrap(); + let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect(); + assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]); +} +``` + +- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL= cargo test -p db --test users` → FAIL (`db::users` missing). + +- [ ] **Step 4: Implement** `crates/db/src/users.rs`: +```rust +//! Users of this organization's instance. All SQL for users lives here. + +use domain::{AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId}; +use serde_json::json; +use sqlx::Row; + +use crate::audit; + +const ENTITY_TYPE: &str = "user"; + +const USER_COLUMNS: &str = "id, email, role"; + +/// Create a user and record a `created` audit entry (email + role only — never the +/// password hash), both on `conn`. Pass a transaction connection. +pub async fn create_user( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + new: &NewUser, +) -> Result { + let id = UserId::new(); + + sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)") + .bind(id.to_uuid()) + .bind(new.email.as_str()) + .bind(&new.password_hash) + .bind(new.role.as_str()) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: vec![ + FieldChange { field: "email".to_owned(), before: None, after: Some(json!(new.email.as_str())) }, + FieldChange { field: "role".to_owned(), before: None, after: Some(json!(new.role.as_str())) }, + ], + }, + ) + .await?; + + Ok(id) +} + +/// Fetch a user by id. +pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1"); + let row = sqlx::query(&sql).bind(id.to_uuid()).fetch_optional(executor).await?; + row.map(map_user).transpose() +} + +/// Fetch a user and their password hash by (normalized) email, for login. +pub async fn credentials_by_email<'e, E>( + executor: E, + email: &str, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE email = $1"); + let row = sqlx::query(&sql).bind(email).fetch_optional(executor).await?; + match row { + Some(row) => { + let hash: String = row.try_get("password_hash")?; + Ok(Some((map_user(row)?, hash))) + } + None => Ok(None), + } +} + +/// List all users, ordered by email. +pub async fn list_users<'e, E>(executor: E) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email"); + let rows = sqlx::query(&sql).fetch_all(executor).await?; + rows.into_iter().map(map_user).collect() +} + +fn map_user(row: sqlx::postgres::PgRow) -> Result { + let role_str: String = row.try_get("role")?; + let role = Role::from_db(&role_str) + .ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?; + Ok(User { + id: UserId::from_uuid(row.try_get("id")?), + email: Email::from_db(row.try_get("email")?), + role, + }) +} +``` +Add to `crates/db/src/lib.rs`: `pub mod users;` (alphabetical with the others). + +- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL= cargo test -p db --test users` → PASS (4 tests). Then `DATABASE_URL= cargo test -p db` → all PASS. + +- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_URL= cargo clippy -p db --all-targets -- -D warnings` → clean. + +- [ ] **Step 7: Commit.** +```bash +git add crates/db +git commit -m "feat(db): users table + repository (create/by_id/by_email/list), audited" +``` + +--- + +## Task 3: `auth` crate — password hashing + extractors + +**Files:** modify root `Cargo.toml` (workspace member + deps); create `crates/auth/Cargo.toml`, `crates/auth/src/lib.rs`. + +- [ ] **Step 1: Workspace + crate setup.** Add `"crates/auth"` to `members` in root `Cargo.toml`, and add the four workspace deps listed under "Workspace dependency additions" above. Create `crates/auth/Cargo.toml`: +```toml +[package] +name = "auth" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +axum.workspace = true +domain = { path = "../domain" } +argon2.workspace = true +tower-sessions.workspace = true +serde.workspace = true +uuid.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true +``` + +- [ ] **Step 2: Write the failing tests** — add a `#[cfg(test)] mod tests` to `crates/auth/src/lib.rs` (the password module is pure and unit-testable; the extractors are integration-tested over HTTP in Task 4): +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify_round_trips() { + let hash = hash_password("correct horse battery staple").unwrap(); + assert!(hash.starts_with("$argon2id$")); + assert!(verify_password("correct horse battery staple", &hash)); + } + + #[test] + fn verify_rejects_wrong_password() { + let hash = hash_password("right").unwrap(); + assert!(!verify_password("wrong", &hash)); + } + + #[test] + fn verify_rejects_malformed_hash() { + assert!(!verify_password("anything", "not-a-phc-string")); + } + + #[test] + fn capability_markers_map_to_domain_capabilities() { + assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers); + assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects); + } +} +``` + +- [ ] **Step 3: Run to verify it fails.** `cargo test -p auth` → FAIL (items missing). + +- [ ] **Step 4: Implement** `crates/auth/src/lib.rs` (adapt argon2/tower-sessions calls to the installed versions if a signature differs; behavior is the contract): +```rust +//! Authentication & authorization: argon2id password hashing and the type-driven +//! axum extractors that gate handlers. Identity is read from the session (set at +//! login); these extractors do not touch the database. + +use std::marker::PhantomData; +use std::sync::OnceLock; + +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::FromRequestParts; +use axum::http::StatusCode; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; +use domain::{Capability, Email, Role, UserId}; +use tower_sessions::Session; + +const SESSION_USER_ID: &str = "user_id"; +const SESSION_EMAIL: &str = "email"; +const SESSION_ROLE: &str = "role"; + +/// Hash a plaintext password as an argon2id PHC string. +pub fn hash_password(plaintext: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default().hash_password(plaintext.as_bytes(), &salt)?.to_string()) +} + +/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a +/// wrong password OR a malformed/unparseable hash (never errors out). +pub fn verify_password(plaintext: &str, phc: &str) -> bool { + let Ok(parsed) = PasswordHash::new(phc) else { + return false; + }; + Argon2::default().verify_password(plaintext.as_bytes(), &parsed).is_ok() +} + +/// Spend a verify's worth of time against a fixed dummy hash. Call this on the +/// "user not found" login path to blunt user-enumeration via response timing. +pub fn verify_dummy(plaintext: &str) { + static DUMMY: OnceLock = OnceLock::new(); + let hash = DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy")); + let _ = verify_password(plaintext, hash); +} + +/// Record the authenticated identity into the session (call after a successful +/// password check). Cycles the session id first to prevent session fixation. +pub async fn establish_session( + session: &Session, + id: UserId, + email: &Email, + role: Role, +) -> Result<(), tower_sessions::session::Error> { + session.cycle_id().await?; + session.insert(SESSION_USER_ID, id.to_uuid()).await?; + session.insert(SESSION_EMAIL, email.as_str()).await?; + session.insert(SESSION_ROLE, role.as_str()).await?; + Ok(()) +} + +/// Rejection for the auth extractors. +#[derive(Debug, Clone, Copy, thiserror::Error)] +pub enum AuthError { + #[error("authentication required")] + Unauthenticated, + #[error("insufficient permissions")] + Forbidden, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + AuthError::Unauthenticated => StatusCode::UNAUTHORIZED, + AuthError::Forbidden => StatusCode::FORBIDDEN, + } + .into_response() + } +} + +/// The authenticated user, reconstructed from the session. Extracting this proves +/// the request carries a valid session (else `401`). +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: UserId, + pub email: Email, + pub role: Role, +} + +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AuthError::Unauthenticated)?; + + let id: uuid::Uuid = session + .get(SESSION_USER_ID) + .await + .ok() + .flatten() + .ok_or(AuthError::Unauthenticated)?; + let email: String = session + .get(SESSION_EMAIL) + .await + .ok() + .flatten() + .ok_or(AuthError::Unauthenticated)?; + let role_str: String = session + .get(SESSION_ROLE) + .await + .ok() + .flatten() + .ok_or(AuthError::Unauthenticated)?; + let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?; + + Ok(AuthUser { id: UserId::from_uuid(id), email: Email::from_db(email), role }) + } +} + +/// A zero-sized type naming a required [`Capability`]. Implementors are used as the +/// type parameter of [`Authorized`]. +pub trait CapabilityMarker { + const CAP: Capability; +} + +/// Require `ManageUsers`. +pub struct ManageUsers; +impl CapabilityMarker for ManageUsers { + const CAP: Capability = Capability::ManageUsers; +} + +/// Require `EditCatalogue`. +pub struct EditCatalogue; +impl CapabilityMarker for EditCatalogue { + const CAP: Capability = Capability::EditCatalogue; +} + +/// Require `PublishObjects`. +pub struct PublishObjects; +impl CapabilityMarker for PublishObjects { + const CAP: Capability = Capability::PublishObjects; +} + +/// Require `ViewInternal`. +pub struct ViewInternal; +impl CapabilityMarker for ViewInternal { + const CAP: Capability = Capability::ViewInternal; +} + +/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized` +/// cannot run without the request's role allowing `C` (else `403`). +#[derive(Debug, Clone)] +pub struct Authorized { + pub user: AuthUser, + _capability: PhantomData, +} + +impl FromRequestParts for Authorized +where + S: Send + Sync, + C: CapabilityMarker, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = AuthUser::from_request_parts(parts, state).await?; + if user.role.allows(C::CAP) { + Ok(Authorized { user, _capability: PhantomData }) + } else { + Err(AuthError::Forbidden) + } + } +} +``` + +- [ ] **Step 5: Run to verify it passes.** `cargo test -p auth` → PASS (4 tests). + +- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `cargo clippy -p auth --all-targets -- -D warnings` → clean. (If clippy flags an unused `CapabilityMarker` impl, that's expected to be fine since they are `pub`; do not delete them — they are the crate's capability vocabulary.) + +- [ ] **Step 7: Commit.** +```bash +git add Cargo.toml crates/auth +git commit -m "feat(auth): argon2id hashing + AuthUser/Authorized session extractors" +``` + +--- + +## Task 4: `api` — session layer + admin surface + +**Files:** modify `crates/api/Cargo.toml`, `crates/api/src/lib.rs`, `crates/api/src/openapi.rs`, `crates/api/tests/health.rs`, `crates/api/tests/public.rs`; create `crates/api/src/admin.rs`, `crates/api/tests/admin.rs`. + +- [ ] **Step 1: Cargo deps.** In `crates/api/Cargo.toml` `[dependencies]` add: +```toml +auth = { path = "../auth" } +tower-sessions.workspace = true +tower-sessions-sqlx-store.workspace = true +``` + +- [ ] **Step 2: Add `cookie_secure` to `AppState`, wire the session layer, expose `migrate_sessions`** in `crates/api/src/lib.rs`: +```rust +mod admin; +mod health; +mod openapi; +mod public; + +use axum::Router; +use db::Db; +use time::Duration; +use tower_sessions::cookie::SameSite; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::PostgresStore; + +/// Shared application state passed to handlers. +#[derive(Clone)] +pub struct AppState { + /// Database handle for this organization. + pub db: Db, + /// User-facing product name (from config). Never hardcoded. + pub app_name: String, + /// Whether the session cookie carries the `Secure` attribute (default true; + /// disable only for plain-HTTP self-hosting). + pub cookie_secure: bool, +} + +/// Build the application router from shared state. +pub fn build_app(state: AppState) -> Router { + let store = PostgresStore::new(state.db.pool().clone()); + let session_layer = SessionManagerLayer::new(store) + .with_name("id") + .with_http_only(true) + .with_secure(state.cookie_secure) + .with_same_site(SameSite::Strict) + .with_expiry(Expiry::OnInactivity(Duration::hours(8))); + + Router::new() + .merge(health::routes()) + .merge(openapi::routes()) + .merge(public::routes()) + .merge(admin::routes()) + .layer(session_layer) + .with_state(state) +} + +/// Create the session store's table if absent. Run once at startup (and in tests +/// before exercising auth). Separate from `Db::migrate` — this is the session +/// library's own bookkeeping table. +pub async fn migrate_sessions(db: &Db) -> Result<(), sqlx::Error> { + PostgresStore::new(db.pool().clone()).migrate().await +} +``` +NOTE: if `PostgresStore::migrate` returns a non-`sqlx::Error` error type in the installed version, adapt the return type (e.g. map into `anyhow` at the call site) — keep the behavior (creates the table). `time::Duration::hours` is available via the `time` workspace dep (already a dependency of `db`/`domain`; add `time.workspace = true` to `api` `[dependencies]` if not present). + +- [ ] **Step 3: Implement** `crates/api/src/admin.rs`: +```rust +//! Admin (authenticated) surface: login/logout/session, user listing, and publishing. + +use auth::{AuthUser, Authorized, ManageUsers, PublishObjects}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use domain::{AuditActor, ObjectId, Visibility}; +use serde::{Deserialize, Serialize}; +use tower_sessions::Session; +use utoipa::ToSchema; + +use crate::AppState; + +/// Credentials for password login. +#[derive(Deserialize, ToSchema)] +pub(crate) struct LoginRequest { + pub email: String, + pub password: String, +} + +/// A user as exposed on the admin surface (no password material). +#[derive(Serialize, ToSchema)] +pub(crate) struct UserView { + pub id: String, + pub email: String, + pub role: String, +} + +/// Desired visibility for a publish/unpublish request. +#[derive(Deserialize, ToSchema)] +pub(crate) struct VisibilityRequest { + pub visibility: Visibility, +} + +/// Log in with email + password. On success, establishes a session (Set-Cookie) and +/// returns 204. On failure, 401 with no detail (no user enumeration). +#[utoipa::path( + post, + path = "/api/admin/login", + request_body = LoginRequest, + responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials")) +)] +pub(crate) async fn login( + State(state): State, + session: Session, + Json(req): Json, +) -> Result { + // Normalize the email the same way storage does; an unparseable email simply + // won't match, but we still spend verify time to resist enumeration. + let normalized = req.email.trim().to_lowercase(); + + let credentials = db::users::credentials_by_email(state.db.pool(), &normalized) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let verified = match &credentials { + Some((_, hash)) => auth::verify_password(&req.password, hash), + None => { + auth::verify_dummy(&req.password); + false + } + }; + if !verified { + return Err(StatusCode::UNAUTHORIZED); + } + + let (user, _) = credentials.expect("verified implies Some"); + auth::establish_session(&session, user.id, &user.email, user.role) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Log out: clear the session. +#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))] +pub(crate) async fn logout(session: Session) -> Result { + session.flush().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(StatusCode::NO_CONTENT) +} + +/// The current authenticated user. +#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))] +pub(crate) async fn me(user: AuthUser) -> Json { + Json(UserView { + id: user.id.to_string(), + email: user.email.as_str().to_owned(), + role: user.role.as_str().to_owned(), + }) +} + +/// List all users (Admin only). +#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))] +pub(crate) async fn list_users( + auth: Authorized, +) -> Result>, StatusCode> { + let _ = auth; // capability proven by the extractor + Err(StatusCode::INTERNAL_SERVER_ERROR) // replaced below +} +``` +WAIT — `list_users` needs `State` to reach the DB. Write it as: +```rust +pub(crate) async fn list_users( + _auth: Authorized, + State(state): State, +) -> Result>, StatusCode> { + let users = db::users::list_users(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json( + users + .into_iter() + .map(|u| UserView { id: u.id.to_string(), email: u.email.as_str().to_owned(), role: u.role.as_str().to_owned() }) + .collect(), + )) +} +``` +(Use this version; the stub above is illustrative. An extractor that is only needed for its guard is conventionally named `_auth`.) + +Continue with the publish handler and routes: +```rust +/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`. +#[utoipa::path( + post, + path = "/api/admin/objects/{id}/visibility", + params(("id" = String, Path, description = "Object id (UUID)")), + request_body = VisibilityRequest, + responses( + (status = 204, description = "Visibility changed"), + (status = 401), (status = 403), + (status = 404, description = "No such object"), + (status = 409, description = "Illegal visibility transition") + ) +)] +pub(crate) async fn set_visibility( + _auth: Authorized, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let object_id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; + + let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Auth events / per-user actor wiring arrive with richer auditing; use System for now. + let result = db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await; + match result { + Ok(()) => { + tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(StatusCode::NO_CONTENT) + } + Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND), + Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT), + Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + +/// Admin routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new() + .route("/api/admin/login", post(login)) + .route("/api/admin/logout", post(logout)) + .route("/api/admin/me", get(me)) + .route("/api/admin/users", get(list_users)) + .route("/api/admin/objects/{id}/visibility", post(set_visibility)) +} +``` +NOTE on actor: this plan records the visibility change with `AuditActor::System` (the per-user `AuditActor::User(id)` wiring belongs with the auth-events work, issue #7 — the `AuthUser` already carries the id, so this is a one-line change later). Leave a `// TODO(#7)` comment. + +- [ ] **Step 4: Register OpenAPI paths + schemas** in `crates/api/src/openapi.rs`: +```rust +use crate::{AppState, admin, health, public}; +``` +```rust +#[openapi( + paths( + health::live, health::ready, + public::list_objects, public::get_object, + admin::login, admin::logout, admin::me, admin::list_users, admin::set_visibility + ), + components(schemas( + health::Live, health::Ready, + public::PublicView, public::PublicObjectPage, + admin::LoginRequest, admin::UserView, admin::VisibilityRequest + )), + info(title = "Collection Management System", version = "0.0.0") +)] +struct ApiDoc; +``` + +- [ ] **Step 5: Update existing test state builders.** In `crates/api/tests/health.rs` and `crates/api/tests/public.rs`, the `state(...)` helpers construct `AppState`; add `cookie_secure: false`: +```rust + AppState { db: db::Db::from_pool(pool), app_name: app_name.to_string(), cookie_secure: false } +``` +(health.rs takes an `app_name` arg; public.rs hardcodes `"Test"` — match each file's existing shape, just add the field.) + +- [ ] **Step 6: Write the failing admin tests** `crates/api/tests/admin.rs`: +```rust +use api::{AppState, build_app, migrate_sessions}; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use db::{catalog, users}; +use domain::{AuditActor, Email, NewUser, ObjectInput, Role, Visibility}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; + +fn state(pool: PgPool) -> AppState { + AppState { db: db::Db::from_pool(pool), app_name: "Test".into(), cookie_secure: false } +} + +async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email: Email::parse(email).unwrap(), + password_hash: auth_hash(password), + role, + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +// Hash via the auth crate so the stored hash verifies. `auth` is a dev-dependency here. +fn auth_hash(password: &str) -> String { + auth::hash_password(password).unwrap() +} + +fn login_request(email: &str, password: &str) -> Request { + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!(r#"{{"email":"{email}","password":"{password}"}}"#))) + .unwrap() +} + +/// Extract the session cookie value from a login response's Set-Cookie header. +fn session_cookie(resp: &axum::http::Response) -> String { + let raw = resp.headers().get(header::SET_COOKIE).expect("Set-Cookie").to_str().unwrap(); + raw.split(';').next().unwrap().to_owned() // "id=..." +} + +#[sqlx::test] +async fn login_then_me_returns_identity(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap(); + seed_user(&pool, "admin@example.com", "s3cret-passw0rd", Role::Admin).await; + let app = build_app(state(pool)); + + let resp = app.clone().oneshot(login_request("admin@example.com", "s3cret-passw0rd")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let cookie = session_cookie(&resp); + + let me = app + .oneshot(Request::builder().uri("/api/admin/me").header(header::COOKIE, &cookie).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(me.status(), StatusCode::OK); + let json: serde_json::Value = serde_json::from_slice(&me.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(json["email"], "admin@example.com"); + assert_eq!(json["role"], "admin"); +} + +#[sqlx::test] +async fn me_without_session_is_401(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap(); + let app = build_app(state(pool)); + let resp = app + .oneshot(Request::builder().uri("/api/admin/me").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test] +async fn wrong_password_is_401(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap(); + seed_user(&pool, "admin@example.com", "right", Role::Admin).await; + let app = build_app(state(pool)); + let resp = app.oneshot(login_request("admin@example.com", "wrong")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test] +async fn editor_cannot_list_users_but_admin_can(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap(); + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + seed_user(&pool, "admin@example.com", "pw-admin-123", Role::Admin).await; + let app = build_app(state(pool)); + + // Editor: 403 + let resp = app.clone().oneshot(login_request("editor@example.com", "pw-editor-123")).await.unwrap(); + let editor_cookie = session_cookie(&resp); + let listed = app + .clone() + .oneshot(Request::builder().uri("/api/admin/users").header(header::COOKIE, &editor_cookie).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::FORBIDDEN); + + // Admin: 200 + let resp = app.clone().oneshot(login_request("admin@example.com", "pw-admin-123")).await.unwrap(); + let admin_cookie = session_cookie(&resp); + let listed = app + .oneshot(Request::builder().uri("/api/admin/users").header(header::COOKIE, &admin_cookie).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn editor_can_publish_via_admin_endpoint(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())).await.unwrap(); + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + + // an internal object the editor will publish (draft -> internal done here, then -> public via API) + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &ObjectInput { + object_number: "P-1".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Internal, + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let app = build_app(state(pool)); + let resp = app.clone().oneshot(login_request("editor@example.com", "pw-editor-123")).await.unwrap(); + let cookie = session_cookie(&resp); + + let publish = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/admin/objects/{id}/visibility")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"visibility":"public"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(publish.status(), StatusCode::NO_CONTENT); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Public); +} +``` +Add `auth = { path = "../auth" }` to `crates/api/Cargo.toml` `[dev-dependencies]` (the tests hash passwords via `auth::hash_password`). + +- [ ] **Step 7: Run to verify it passes.** `DATABASE_URL= cargo test -p api` → all PASS (health + public + the 5 admin tests). Iterate on any tower-sessions/axum signature mismatches until green (the tests are the contract). + +- [ ] **Step 8: Lint.** `cargo +nightly fmt`; `DATABASE_URL= cargo clippy -p api --all-targets -- -D warnings` → clean. + +- [ ] **Step 9: Commit.** +```bash +git add crates/api +git commit -m "feat(api): admin auth surface (login/logout/me/users/publish) on tower-sessions" +``` + +--- + +## Task 5: `server` — session migration + `create-user` CLI + +**Files:** modify `crates/server/Cargo.toml`, `crates/server/src/config.rs`, `crates/server/src/lib.rs`, `crates/server/src/main.rs`, `crates/server/tests/config.rs`. + +- [ ] **Step 1: Cargo deps.** In `crates/server/Cargo.toml` `[dependencies]` add: +```toml +auth = { path = "../auth" } +domain = { path = "../domain" } +tower-sessions-sqlx-store.workspace = true +rpassword.workspace = true +``` +(Add `tower-sessions-sqlx-store` only if `create_user`/startup references it directly; the session migration goes through `api::migrate_sessions`, so it likely is NOT needed here — omit unless the compiler asks.) + +- [ ] **Step 2: Add `cookie_secure` to `Config`** in `crates/server/src/config.rs`: +```rust + /// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable + /// only for plain-HTTP self-hosting behind no TLS at all. + #[arg(long, env = "SESSION_COOKIE_SECURE", default_value_t = true)] + pub cookie_secure: bool, +``` + +- [ ] **Step 3: Wire session migration + the `create_user` function** in `crates/server/src/lib.rs`. Update `run` to migrate the session store and pass `cookie_secure`, and add `create_user`: +```rust +use anyhow::Context; +use api::{AppState, build_app, migrate_sessions}; +use db::Db; +use domain::{AuditActor, Email, NewUser, Role}; +use tokio::net::TcpListener; + +pub async fn run(config: Config) -> anyhow::Result<()> { + let db = Db::connect(&config.database_url).await.context("connecting to the database")?; + db.migrate().await.context("running database migrations")?; + migrate_sessions(&db).await.context("creating the session store")?; + + let state = AppState { + db, + app_name: config.app_name.clone(), + cookie_secure: config.cookie_secure, + }; + + let listener = TcpListener::bind(&config.bind_addr) + .await + .with_context(|| format!("binding to {}", config.bind_addr))?; + tracing::info!(addr = %config.bind_addr, "server listening"); + serve(listener, state).await +} + +/// Create a user from the CLI (admin bootstrap). Reads the password from the +/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input). +pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> { + let email = Email::parse(email).map_err(|e| anyhow::anyhow!("{e}"))?; + + let password = match std::env::var("BOOTSTRAP_PASSWORD") { + Ok(p) => p, + Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, + }; + anyhow::ensure!(password.len() >= 8, "password must be at least 8 characters"); + + let password_hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!("hashing password: {e}"))?; + + let db = Db::connect(database_url).await.context("connecting to the database")?; + let mut tx = db.pool().begin().await?; + let id = db::users::create_user(&mut tx, AuditActor::System, &NewUser { email, password_hash, role }) + .await + .context("creating the user (is the email already taken?)")?; + tx.commit().await?; + + println!("created user {id} ({role:?})"); + Ok(()) +} +``` +Keep the existing `serve` function as-is. + +- [ ] **Step 4: Add the subcommand CLI** in `crates/server/src/main.rs`: +```rust +use clap::{Parser, Subcommand, ValueEnum}; +use domain::Role; +use server::{Config, create_user, run}; + +#[derive(Parser)] +#[command(version, about = "Collection management system server")] +struct Cli { + #[command(subcommand)] + command: Option, + #[command(flatten)] + config: Config, +} + +#[derive(Subcommand)] +enum Command { + /// Create a user (admin bootstrap). + CreateUser { + #[arg(long)] + email: String, + #[arg(long, value_enum)] + role: RoleArg, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum RoleArg { + Admin, + Editor, +} + +impl From for Role { + fn from(r: RoleArg) -> Self { + match r { + RoleArg::Admin => Role::Admin, + RoleArg::Editor => Role::Editor, + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + match cli.command { + None => run(cli.config).await, + Some(Command::CreateUser { email, role }) => { + create_user(&cli.config.database_url, &email, role.into()).await + } + } +} +``` +NOTE: flattening `Config` means `create-user` also accepts `--database-url`/`DATABASE_URL` (which it needs) and the serve-only options keep their defaults (unused for `create-user`). This preserves the existing default-to-serve behavior (`server` with no subcommand still serves). + +- [ ] **Step 5: Update the config test** `crates/server/tests/config.rs` — the existing tests parse `Config`; `cookie_secure` has a default so they should still pass, but add a check and ensure parsing still works. If a test asserts an exact field set, add `cookie_secure` defaulting to `true`. Add: +```rust +#[test] +fn cookie_secure_defaults_to_true() { + let config = Config::try_parse_from(["server", "--database-url", "postgres://x"]).unwrap(); + assert!(config.cookie_secure); +} +``` +(Match the file's existing import of `Config`/`clap::Parser` and the `temp-env` isolation pattern used by the other config tests if `DATABASE_URL` is read from env.) + +- [ ] **Step 6: Run to verify it passes.** Build + tests: +```bash +DATABASE_URL= cargo test -p server +``` +Expected: PASS (config tests + the existing serve test). If the serve test builds an `AppState`, add `cookie_secure: false` there too. + +- [ ] **Step 7: Manual smoke (optional, document only).** `BOOTSTRAP_PASSWORD=changeme cargo run -p server -- create-user --email admin@example.com --role admin` creates the first admin; `cargo run -p server` then serves. (Not a CI step.) + +- [ ] **Step 8: Full workspace check.** +```bash +cargo +nightly fmt --check +DATABASE_URL= cargo clippy --workspace --all-targets -- -D warnings +DATABASE_URL= MEILI_URL= MEILI_MASTER_KEY= cargo test --workspace +``` +Expected: all green. + +- [ ] **Step 9: Commit.** +```bash +git add crates/server Cargo.toml +git commit -m "feat(server): create-user CLI + session-store migration on startup" +``` + +--- + +## Self-Review (completed) + +**Spec coverage (VISION "Authentication & access control" [MVP]; arch spec §10, §9, §7):** +- Email/password scoped to the single org → Tasks 2–4. ✓ (OIDC deferred — own plan.) +- Sessions without Redis (server-side, in the org DB) → tower-sessions + PostgresStore. ✓ +- Authorization via typed extractors (`AuthUser` / `Authorized`); privileged handler can't compile without the capability → Task 3. ✓ +- Role/permission model kept simple (Admin/Editor + capability policy) → Task 1. ✓ +- Clean public/admin split (`/api/admin/**` authenticated; public untouched) → Task 4. ✓ +- All SQL in `db`; `auth` is DB-free; `api`/`server` wire it → crate deps. ✓ +- Audited user creation (email+role, never the hash) → Task 2 + test. ✓ + +**Placeholder scan:** the `list_users` "stub" block in Task 4 Step 3 is explicitly replaced by the version immediately below it (called out in prose). ``/`` are documented env values. No other placeholders. + +**Type consistency:** `UserId`/`Email`/`Role`/`Capability`/`User`/`NewUser` defined in Task 1 and consumed by Tasks 2–5; `db::users::{create_user, user_by_id, credentials_by_email, list_users}` defined in Task 2 and consumed in Task 4; `auth::{hash_password, verify_password, verify_dummy, establish_session, AuthUser, Authorized, ManageUsers, PublishObjects, CapabilityMarker, AuthError}` defined in Task 3 and consumed in Task 4; `AppState` gains `cookie_secure` (Task 4) consistently updated in every constructor (api tests + server). Reuses Plan 7's `db::catalog::set_visibility` + `VisibilityError` for the publish endpoint. + +## Notes for follow-on plans +- **Closes issue #15** (admin endpoint to trigger visibility transitions) via `POST /api/admin/objects/{id}/visibility`. Close #15 on merge. +- **Per-user audit actor (issue #7):** the publish handler records `AuditActor::System`; switch to `AuditActor::User(auth.user.id)` and add login/logout/auth-event auditing in the #7 work. The `AuthUser` already carries the id (one-line change, marked `// TODO(#7)`). +- **OIDC plan (next auth phase):** add an OIDC relying-party flow (discovery, authorize redirect, callback, token exchange) that, on success, calls the same `auth::establish_session` — the session/extractor layer is provider-agnostic. +- **Admin CRUD surface:** object/vocabulary/authority/field create-edit endpoints behind `Authorized` — their own plan; the extractor framework is ready. +- **User management API:** create/disable/role-change users behind `Authorized` (this plan ships read-only `/users` + the CLI bootstrap). +- **Login hardening (post-MVP):** rate limiting / lockout, password strength policy, optional CSRF double-submit token on top of SameSite=Strict.