diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 2fe9819..ba4f571 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -122,6 +122,11 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router .route("/api/auth/me", axum::routing::get(auth_me)) .route("/api/auth/tokens", axum::routing::get(list_tokens).post(create_token)) .route("/api/auth/tokens/:token_uid", axum::routing::delete(delete_token)) + .route("/api/admin/users", get(admin_list_users).post(admin_create_user)) + .route("/api/admin/users/:uid/status", axum::routing::patch(admin_set_user_status)) + .route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role)) + .route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role)) + .route("/api/admin/roles", get(admin_list_roles).post(admin_create_role)) .nest_service("/assets", ServeDir::new(static_dir.join("assets"))) .fallback_service(ServeFile::new(static_dir.join("index.html"))) .layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) @@ -590,6 +595,109 @@ async fn delete_token( } } +#[derive(Debug, serde::Deserialize)] +struct AdminCreateUserBody { username: String, password: String, email: Option } + +#[derive(Debug, serde::Deserialize)] +struct AdminSetStatusBody { status: String } + +#[derive(Debug, serde::Deserialize)] +struct AdminAssignRoleBody { role_slug: String } + +#[derive(Debug, serde::Deserialize)] +struct AdminCreateRoleBody { slug: String, name: String } + +async fn admin_list_users( + State(state): State, + auth_user: AuthUser, +) -> Result>, ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + let conn = database::open_auth_db(&state.auth_db_path)?; + Ok(Json(database::list_users(&conn)?)) +} + +async fn admin_create_user( + State(state): State, + auth_user: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + let (caller_id, _) = auth_user.require_auth()?; + if body.username.trim().is_empty() || body.password.len() < 8 { + return Err(ApiError::bad_request("username required, password >= 8 chars")); + } + let conn = database::open_auth_db(&state.auth_db_path)?; + let hash = auth::hash_password(&body.password).map_err(ApiError::from)?; + let uid = database::create_user(&conn, &body.username, body.email.as_deref(), &hash, caller_id)?; + Ok((StatusCode::CREATED, Json(serde_json::json!({ "user_uid": uid, "username": body.username })))) +} + +async fn admin_set_user_status( + State(state): State, + auth_user: AuthUser, + Path(uid): Path, + Json(body): Json, +) -> Result, ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + if body.status != "active" && body.status != "disabled" { + return Err(ApiError::bad_request("status must be 'active' or 'disabled'")); + } + let conn = database::open_auth_db(&state.auth_db_path)?; + if !database::set_user_status(&conn, &uid, &body.status)? { + return Err(ApiError::not_found("user not found")); + } + Ok(Json(serde_json::json!({ "user_uid": uid, "status": body.status }))) +} + +async fn admin_assign_role( + State(state): State, + auth_user: AuthUser, + Path(uid): Path, + Json(body): Json, +) -> Result { + auth_user.require_role(ROLE_ADMIN)?; + let (caller_id, _) = auth_user.require_auth()?; + let conn = database::open_auth_db(&state.auth_db_path)?; + let target_id = database::get_user_id_by_uid(&conn, &uid)? + .ok_or_else(|| ApiError::not_found("user not found"))?; + database::assign_role(&conn, target_id, &body.role_slug, caller_id)?; + Ok(StatusCode::OK) +} + +async fn admin_remove_role( + State(state): State, + auth_user: AuthUser, + Path((uid, role_slug)): Path<(String, String)>, +) -> Result { + auth_user.require_role(ROLE_ADMIN)?; + let conn = database::open_auth_db(&state.auth_db_path)?; + let target_id = database::get_user_id_by_uid(&conn, &uid)? + .ok_or_else(|| ApiError::not_found("user not found"))?; + database::remove_role(&conn, target_id, &role_slug)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn admin_list_roles( + State(state): State, + auth_user: AuthUser, +) -> Result>, ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + let conn = database::open_auth_db(&state.auth_db_path)?; + Ok(Json(database::list_roles(&conn)?)) +} + +async fn admin_create_role( + State(state): State, + auth_user: AuthUser, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + auth_user.require_role(ROLE_ADMIN)?; + let conn = database::open_auth_db(&state.auth_db_path)?; + let role = database::create_custom_role(&conn, &body.slug, &body.name) + .map_err(ApiError::from)?; + Ok((StatusCode::CREATED, Json(role))) +} + fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, @@ -1507,4 +1615,26 @@ mod tests { assert!(json["job_uid"].as_str().is_some(), "response must have job_uid"); assert_eq!(json["status"], "pending"); } + + #[tokio::test] + async fn admin_users_requires_admin_role() { + let (test_app, _dir) = make_test_app(); + let response = test_app.oneshot( + Request::builder().uri("/api/admin/users").body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn admin_list_users_returns_ok_for_admin() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _, auth_path) = make_test_registry(&dir); + let session_cookie = make_test_session(&auth_path); + let response = app(registry, auth_path).oneshot( + Request::builder().uri("/api/admin/users") + .header("cookie", &session_cookie) + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } }