feat(api): admin auth surface (login/logout/me/users/publish) on tower-sessions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:54:03 +02:00
parent 4e7288731a
commit 5135aeee6c
7 changed files with 469 additions and 3 deletions
+177
View File
@@ -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<AppState>,
session: Session,
Json(req): Json<LoginRequest>,
) -> Result<StatusCode, StatusCode> {
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<StatusCode, StatusCode> {
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<UserView> {
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<ManageUsers>,
State(state): State<AppState>,
) -> Result<Json<Vec<UserView>>, 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<PublishObjects>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<VisibilityRequest>,
) -> Result<StatusCode, StatusCode> {
let object_id = id.parse::<ObjectId>().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<AppState> {
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))
}
+26
View File
@@ -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
}
+18 -3
View File
@@ -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<AppState>) -> Json<utoipa::openapi::OpenApi> {
let mut doc = ApiDoc::openapi();
doc.info.title = state.app_name.clone();
Json(doc)
}