diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 5eda5af..a50774f 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" } @@ -17,3 +22,4 @@ tower.workspace = true http-body-util.workspace = true serde_json.workspace = true sqlx.workspace = true +auth = { path = "../auth" } diff --git a/crates/api/src/admin.rs b/crates/api/src/admin.rs new file mode 100644 index 0000000..593fc60 --- /dev/null +++ b/crates/api/src/admin.rs @@ -0,0 +1,177 @@ +//! 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 { + 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..4bb918e --- /dev/null +++ b/crates/api/tests/admin.rs @@ -0,0 +1,240 @@ +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); +} 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, } }