180 lines
5.7 KiB
Rust
180 lines
5.7 KiB
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,
|
|
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> {
|
|
// 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::<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))
|
|
}
|