From 6f819cf00772258bd8aed06dac0d81f362264aa1 Mon Sep 17 00:00:00 2001 From: "J.C. Jones" Date: Thu, 7 May 2026 10:21:05 -0700 Subject: [PATCH 1/2] Migrate from Trillium [part 7C]: admin routes and Trillium router removal - Move the admin queue routes (index, show, delete) to Axum handlers gated by the new AdminPermissionsActor extractor, which centralizes the admin-only check (returning 404 for non-admins to hide endpoint existence). This is enforced by using a nested router, which has a `route_layer` that layers `admin::require_admin` on top of everything in that router, so we don't forget anything. - Wire ReplaceMimeTypesLayer on the Axum /api sub-router now that all API routes are served by Axum. Removed the Trillium routes() and api_routes() functions, the api() handler chain, handler/misc.rs (actor_required, admin_required), and all FromConn impls from route files. Then I realized this is supposed to be in Part 8 and I stopped. - Add #[serde(alias)]es to JobStatus so query-param deserialization accepts lowercase values (matching the previous QueryStrong behavior). - Add #[cfg(all(assets)...] to a couple of telemetry things so we don't commit compilation crimes. These'll get cleaned up when we drop trillium_opentelemetry, I think. Note that there is still cleanup to do in Part 8, even though the migration is mostly done here. I wasn't super disciplined with what I deleted and what I marked dead, for which I request grace. Just, one thing at a time. --- src/entity/queue.rs | 3 + src/handler.rs | 76 ++++------------- src/handler/cors.rs | 1 + src/handler/custom_mime_types.rs | 4 +- src/handler/misc.rs | 21 ----- src/handler/session_store.rs | 2 + src/lib.rs | 3 +- src/permissions.rs | 24 ++++++ src/routes.rs | 59 ++++--------- src/routes/accounts.rs | 23 +----- src/routes/admin.rs | 124 +++++++++++++++------------- src/routes/aggregators.rs | 30 +------ src/routes/api_tokens.rs | 23 +----- src/routes/collector_credentials.rs | 26 +----- src/routes/tasks.rs | 21 ----- 15 files changed, 133 insertions(+), 307 deletions(-) delete mode 100644 src/handler/misc.rs diff --git a/src/entity/queue.rs b/src/entity/queue.rs index 8b2f60339..e4afca0a9 100644 --- a/src/entity/queue.rs +++ b/src/entity/queue.rs @@ -34,10 +34,13 @@ pub struct Model { #[sea_orm(rs_type = "i32", db_type = "Integer")] pub enum JobStatus { #[sea_orm(num_value = 0)] + #[serde(alias = "pending")] Pending, #[sea_orm(num_value = 1)] + #[serde(alias = "success")] Success, #[sea_orm(num_value = 2)] + #[serde(alias = "failed")] Failed, } diff --git a/src/handler.rs b/src/handler.rs index b6b5909cd..f02de3a54 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -6,7 +6,6 @@ pub(crate) mod custom_mime_types; pub(crate) mod error; pub(crate) mod extract; pub(crate) mod logger; -pub(crate) mod misc; pub(crate) mod oauth2; pub(crate) mod opentelemetry; pub(crate) mod origin_router; @@ -19,41 +18,32 @@ use trillium_client::Client; use axum::extract::{DefaultBodyLimit, FromRef}; use axum::http::{header, HeaderValue}; -use cors::{axum_cors_layer, cors_headers}; +use cors::axum_cors_layer; use error::ErrorHandler; use logger::logger; use oauth2::OauthClient; use proxy::AxumProxy; -use session_store::{axum_session_layer, SessionStore}; +use session_store::axum_session_layer; use std::{borrow::Cow, net::Ipv6Addr, net::SocketAddr, sync::Arc}; use tokio::net::TcpListener; use tower::ServiceBuilder; use tower_http::compression::CompressionLayer; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; -use trillium::{state, Handler, Info}; -use trillium_caching_headers::{ - cache_control, caching_headers, - CacheControlDirective::{MustRevalidate, Private}, -}; -use trillium_compression::compression; +use trillium::{Handler, Info}; +use trillium_caching_headers::caching_headers; use trillium_conn_id::conn_id; -use trillium_cookies::cookies; use trillium_forwarding::Forwarding; use trillium_macros::Handler; -use trillium_sessions::sessions; - -pub(crate) use custom_mime_types::ReplaceMimeTypes; -pub(crate) use misc::*; pub use error::Error; pub use origin_router::origin_router; use self::opentelemetry::opentelemetry; -#[cfg(feature = "otlp-trace")] +#[cfg(all(assets, feature = "otlp-trace"))] use trillium_opentelemetry::global::instrument_handler; -#[cfg(not(feature = "otlp-trace"))] +#[cfg(all(assets, not(feature = "otlp-trace")))] fn instrument_handler(handler: impl Handler) -> impl Handler { handler } @@ -175,7 +165,7 @@ impl DivviupApi { .route("/login", axum::routing::get(oauth2::redirect)) .route("/logout", axum::routing::get(oauth2::logout)) .route("/callback", axum::routing::get(oauth2::callback)) - .nest("/api", axum_routes::api_router()) + .nest("/api", axum_routes::api_router(&axum_state)) .layer(middleware) .with_state(axum_state); let axum_listener = TcpListener::bind((Ipv6Addr::LOCALHOST, 0)) @@ -203,7 +193,8 @@ impl DivviupApi { logger(), #[cfg(assets)] instrument_handler(assets::static_assets(&config)), - instrument_handler(api(&db, &config, auth0_client)), + #[cfg(feature = "test-header-injection")] + inject_test_user_trillium, proxy, ErrorHandler, )), @@ -237,23 +228,13 @@ impl AsRef for DivviupApi { } } -/// Trillium-side test shim that bridges user injection between the Trillium -/// and Axum worlds during the migration: -/// -/// - If the `X-Integration-Testing-User` header is present (set by -/// `TestExt::with_user`), deserialize the user into connection state so -/// `actor_required` passes. -/// - If a `User` was injected via `.with_state()` (legacy test pattern), -/// serialize it into the header so the proxy forwards it to Axum. +/// Trillium-side test shim: if a `User` was injected via `.with_state()` +/// (legacy test pattern), serialize it into the `X-Integration-Testing-User` +/// header so the proxy forwards it to Axum. +// TODO: remove in Part 8 (Trillium removal) #[cfg(feature = "test-header-injection")] async fn inject_test_user_trillium(mut conn: trillium::Conn) -> trillium::Conn { - if let Some(user) = conn - .request_headers() - .get_str("x-integration-testing-user") - .and_then(|v| serde_json::from_str::(v).ok()) - { - conn.insert_state(user); - } else if let Some(json) = conn + if let Some(json) = conn .state::() .and_then(|u| serde_json::to_string(u).ok()) { @@ -296,32 +277,3 @@ impl NamedHandler { Self(handler, name.into()) } } - -fn api(db: &Db, config: &Config, auth0_client: Auth0Client) -> impl Handler { - NamedHandler::new( - "api", - ( - instrument_handler(compression()), - #[cfg(feature = "integration-testing")] - state(crate::User::for_integration_testing()), - #[cfg(feature = "test-header-injection")] - inject_test_user_trillium, - instrument_handler(cookies()), - instrument_handler( - sessions( - SessionStore::new(db.clone()), - &config.session_secrets.current, - ) - .with_cookie_name(session_store::SESSION_COOKIE_NAME) - .with_older_secrets(&config.session_secrets.older), - ), - state(config.client.clone()), - state(config.crypter.clone()), - state(config.feature_flags()), - instrument_handler(cors_headers(config)), - cache_control([Private, MustRevalidate]), - db.clone(), - instrument_handler(routes(auth0_client)), - ), - ) -} diff --git a/src/handler/cors.rs b/src/handler/cors.rs index b236bdf01..ee1f78a40 100644 --- a/src/handler/cors.rs +++ b/src/handler/cors.rs @@ -53,6 +53,7 @@ impl CorsHeaders { } } +#[expect(dead_code)] // TODO: remove in Part 8 (Trillium removal) pub fn cors_headers(config: &Config) -> impl Handler { CorsHeaders::new(config) } diff --git a/src/handler/custom_mime_types.rs b/src/handler/custom_mime_types.rs index f5e864695..a144e3388 100644 --- a/src/handler/custom_mime_types.rs +++ b/src/handler/custom_mime_types.rs @@ -12,9 +12,9 @@ use trillium::{ }; pub const DIVVIUP_API_MEDIA_TYPE: &str = "application/vnd.divviup+json;version=0.1"; -#[cfg_attr(not(test), expect(dead_code))] // Used once ReplaceMimeTypesLayer is wired; see TODO in routes.rs. const APPLICATION_JSON: header::HeaderValue = header::HeaderValue::from_static("application/json"); +#[expect(dead_code)] // TODO: remove in Part 8 (Trillium removal) pub struct ReplaceMimeTypes; #[trillium::async_trait] @@ -50,7 +50,6 @@ impl Handler for ReplaceMimeTypes { /// This replicates the Trillium [`ReplaceMimeTypes`] handler for Axum routes: /// requests with the custom content type (or no content type) have their headers /// normalized to `application/json`; responses get the custom content type set. -#[cfg_attr(not(test), expect(dead_code))] // Wired once the Trillium proxy is removed; see TODO in routes.rs. #[derive(Clone, Debug)] pub struct ReplaceMimeTypesLayer; @@ -62,7 +61,6 @@ impl Layer for ReplaceMimeTypesLayer { } /// Tower [`Service`] produced by [`ReplaceMimeTypesLayer`]. -#[cfg_attr(not(test), expect(dead_code))] // Constructed by ReplaceMimeTypesLayer. #[derive(Clone, Debug)] pub struct ReplaceMimeTypesService { inner: S, diff --git a/src/handler/misc.rs b/src/handler/misc.rs deleted file mode 100644 index e3130f3ab..000000000 --- a/src/handler/misc.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::PermissionsActor; -use trillium::{Conn, Handler, Status}; -use trillium_api::Halt; - -pub async fn actor_required(_: &mut Conn, actor: Option) -> impl Handler { - if actor.is_none() { - Some((Status::Forbidden, Halt)) - } else { - None - } -} - -pub async fn admin_required(_: &mut Conn, actor: Option) -> impl Handler { - if matches!(actor, Some(actor) if actor.is_admin()) { - None - } else { - // we return not found instead of forbidden so as to not - // reveal what admin endpoints exist - Some((Status::NotFound, Halt)) - } -} diff --git a/src/handler/session_store.rs b/src/handler/session_store.rs index ab0465308..341e76901 100644 --- a/src/handler/session_store.rs +++ b/src/handler/session_store.rs @@ -25,11 +25,13 @@ use tower_sessions::{ // note will no longer apply. pub const SESSION_COOKIE_NAME: &str = "divviup.sid"; +#[allow(dead_code)] // TODO: remove in Part 8 (Trillium removal) #[derive(Debug, Clone)] pub struct SessionStore { db: Db, } +#[allow(dead_code)] // TODO: remove in Part 8 (Trillium removal) impl SessionStore { pub fn new(db: Db) -> Self { Self { db } diff --git a/src/lib.rs b/src/lib.rs index 23ebf4f97..3891dc941 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,9 +27,8 @@ pub use crypter::Crypter; pub use db::Db; pub use handler::{custom_mime_types::DIVVIUP_API_MEDIA_TYPE, AxumAppState, DivviupApi, Error}; pub use opentelemetry; -pub use permissions::{Permissions, PermissionsActor}; +pub use permissions::{AdminPermissionsActor, Permissions, PermissionsActor}; pub use queue::Queue; -pub use routes::routes; use serde::{Deserialize, Deserializer}; pub use user::{User, USER_SESSION_KEY}; diff --git a/src/permissions.rs b/src/permissions.rs index 02e3f0d5e..00955a6b3 100644 --- a/src/permissions.rs +++ b/src/permissions.rs @@ -139,6 +139,30 @@ where } } +/// A [`PermissionsActor`] that has been verified to be an admin. +/// +/// Use this as an Axum extractor on admin-only routes. Returns +/// [`Error::NotFound`] for non-admins, hiding the endpoint's existence. +#[derive(Debug, Clone)] +pub struct AdminPermissionsActor(pub PermissionsActor); + +impl FromRequestParts for AdminPermissionsActor +where + Db: FromRef, + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let actor = PermissionsActor::from_request_parts(parts, state).await?; + if actor.is_admin() { + Ok(Self(actor)) + } else { + Err(Error::NotFound) + } + } +} + pub trait Permissions { fn allow_read(&self, actor: &PermissionsActor) -> bool { self.allow_write(actor) diff --git a/src/routes.rs b/src/routes.rs index 2995220cb..1c222128d 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -8,58 +8,18 @@ mod memberships; mod tasks; mod users; -use crate::{ - clients::Auth0Client, - handler::{actor_required, ReplaceMimeTypes}, -}; pub use health_check::health_check; -use trillium::{ - state, Handler, - Method::{Delete, Get, Patch, Post}, -}; -use trillium_api::api; -use trillium_router::router; - -pub fn routes(auth0_client: Auth0Client) -> impl Handler { - router().any( - &[Get, Post, Delete, Patch], - "/api/*", - (state(auth0_client), api_routes()), - ) -} - -fn api_routes() -> impl Handler { - // ReplaceMimeTypes stays in the outer chain because the trillium-router's - // before_send does not replay path adjustments, so handlers inside route - // entries never get their before_send called for wildcard matches. Keeping - // it here means it also transforms headers for requests that fall through - // to the Axum proxy, but that's harmless: Trillium already validated them, - // and the Axum side accepts pre-transformed application/json. - ( - ReplaceMimeTypes, - api(actor_required), - router().all("/admin/*", admin::routes()), - ) -} pub(crate) mod axum_routes { use super::{ - accounts, aggregators::axum_handler as aggregators, api_tokens, collector_credentials, - memberships, tasks::axum_handler as tasks, users, + accounts, admin::axum_handler as admin, aggregators::axum_handler as aggregators, + api_tokens, collector_credentials, memberships, tasks::axum_handler as tasks, users, }; - use crate::handler::AxumAppState; + use crate::handler::{custom_mime_types::ReplaceMimeTypesLayer, AxumAppState}; use axum::routing::{delete, get}; /// Axum sub-router for `/api` routes. - /// - /// During the proxy migration, Trillium's `ReplaceMimeTypes` handler in - /// the outer chain already validates/normalizes request headers and sets - /// the response Content-Type via `before_send`. So we do NOT apply - /// `ReplaceMimeTypesLayer` here — it would reject the already-normalized - /// `application/json` Content-Type that Trillium forwarded. - /// - /// TODO: wire `ReplaceMimeTypesLayer` once the Trillium proxy is removed. - pub fn api_router() -> axum::Router { + pub fn api_router(state: &AxumAppState) -> axum::Router { axum::Router::new() .route("/users/me", get(users::show)) .route("/accounts", get(accounts::index).post(accounts::create)) @@ -88,6 +48,16 @@ pub(crate) mod axum_routes { "/tasks/{task_id}", get(tasks::show).patch(tasks::update).delete(tasks::delete), ) + .nest( + "/admin", + axum::Router::new() + .route("/queue", get(admin::index)) + .route("/queue/{job_id}", get(admin::show).delete(admin::delete)) + .route_layer(axum::middleware::from_fn_with_state( + state.clone(), + admin::require_admin, + )), + ) .nest( "/accounts/{account_id}", axum::Router::new() @@ -110,5 +80,6 @@ pub(crate) mod axum_routes { get(aggregators::index_for_account).post(aggregators::create), ), ) + .layer(ReplaceMimeTypesLayer) } } diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs index a1eb675da..e90162455 100644 --- a/src/routes/accounts.rs +++ b/src/routes/accounts.rs @@ -9,11 +9,7 @@ use axum::{ response::IntoResponse, }; use httpdate::fmt_http_date; -use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait}; -use trillium::Conn; -use trillium_api::FromConn; -use trillium_router::RouterConnExt; -use uuid::Uuid; +use sea_orm::{ActiveModelTrait, TransactionTrait}; impl Permissions for Account { fn allow_write(&self, actor: &PermissionsActor) -> bool { @@ -21,23 +17,6 @@ impl Permissions for Account { } } -#[trillium::async_trait] -impl FromConn for Account { - async fn from_conn(conn: &mut Conn) -> Option { - let actor = PermissionsActor::from_conn(conn).await?; - let db: &Db = conn.state()?; - let account_id = conn.param("account_id")?.parse::().ok()?; - match Accounts::find_by_id(account_id).one(db).await { - Ok(Some(account)) => actor.if_allowed(conn.method(), account), - Ok(None) => None, - Err(error) => { - conn.insert_state(Error::from(error)); - None - } - } - } -} - impl FromRequestParts for Account where Db: FromRef, diff --git a/src/routes/admin.rs b/src/routes/admin.rs index ea28ed8ac..ba2671e51 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,77 +1,87 @@ use crate::{ entity::queue::{self, Column, Entity, JobStatus, Model}, - handler::{admin_required, Error}, - Db, + handler::extract::Json, + Db, Error, PermissionsActor, }; -use querystrong::QueryStrong; +use axum::extract::{FromRef, FromRequestParts, Path, Query, Request, State}; +use axum::http::{header, request::Parts, StatusCode}; +use axum::middleware::Next; +use axum::response::IntoResponse; +use httpdate::fmt_http_date; use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryOrder, QuerySelect}; -use trillium::{async_trait, Conn, Handler, Status}; -use trillium_api::{api, FromConn, Json}; -use trillium_caching_headers::CachingHeadersExt; -use trillium_router::{router, RouterConnExt}; +use serde::Deserialize; +use std::collections::HashMap; use uuid::Uuid; -pub fn routes() -> impl Handler { - ( - api(admin_required), - router() - .get("/queue", api(index)) - .get("/queue/:job_id", api(show)) - .delete("/queue/:job_id", api(delete)), - ) -} +impl FromRequestParts for queue::Model +where + Db: FromRef, + S: Send + Sync, +{ + type Rejection = Error; + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Path(params) = Path::>::from_request_parts(parts, state) + .await + .map_err(|_| Error::NotFound)?; -#[async_trait] -impl FromConn for queue::Model { - async fn from_conn(conn: &mut Conn) -> Option { - let db = Db::from_conn(conn).await?; - let id: Uuid = conn.param("job_id")?.parse().ok()?; + let id = params + .get("job_id") + .and_then(|s| s.parse::().ok()) + .ok_or(Error::NotFound)?; - match Entity::find_by_id(id).one(&db).await { - Ok(job) => job, - Err(error) => { - conn.insert_state(Error::from(error)); - None - } - } + let db = Db::from_ref(state); + Entity::find_by_id(id) + .one(&db) + .await? + .ok_or(Error::NotFound) } } -async fn index(conn: &mut Conn, db: Db) -> Result>, Error> { - let params = QueryStrong::parse(conn.querystring()); - let mut find = Entity::find(); - let query = QuerySelect::query(&mut find); - match params.get_str("status") { - Some("pending") => { - query.cond_where(Column::Status.eq(JobStatus::Pending)); - } +#[derive(Deserialize)] +pub struct IndexParams { + status: Option, +} - Some("success") => { - query.cond_where(Column::Status.eq(JobStatus::Success)); - } +pub mod axum_handler { + use super::*; - Some("failed") => { - query.cond_where(Column::Status.eq(JobStatus::Failed)); + pub async fn require_admin( + actor: PermissionsActor, + request: Request, + next: Next, + ) -> axum::response::Response { + if actor.is_admin() { + next.run(request).await + } else { + StatusCode::NOT_FOUND.into_response() } - - _ => {} } - find.order_by_desc(Column::UpdatedAt) - .limit(100) - .all(&db) - .await - .map(Json) - .map_err(Error::from) -} + pub async fn index( + State(db): State, + Query(params): Query, + ) -> Result>, Error> { + let mut find = Entity::find(); + if let Some(status) = params.status { + let query = QuerySelect::query(&mut find); + query.cond_where(Column::Status.eq(status)); + } -async fn show(conn: &mut Conn, queue_job: Model) -> Json { - conn.set_last_modified(queue_job.updated_at.into()); + Ok(Json( + find.order_by_desc(Column::UpdatedAt) + .limit(100) + .all(&db) + .await?, + )) + } - Json(queue_job) -} + pub async fn show(queue_job: Model) -> impl IntoResponse { + let last_modified = fmt_http_date(queue_job.updated_at.into()); + ([(header::LAST_MODIFIED, last_modified)], Json(queue_job)) + } -async fn delete(_: &mut Conn, (queue_job, db): (Model, Db)) -> Result { - queue_job.delete(&db).await?; - Ok(Status::NoContent) + pub async fn delete(queue_job: Model, State(db): State) -> Result { + queue_job.delete(&db).await?; + Ok(StatusCode::NO_CONTENT) + } } diff --git a/src/routes/aggregators.rs b/src/routes/aggregators.rs index 899d71602..61f869c1d 100644 --- a/src/routes/aggregators.rs +++ b/src/routes/aggregators.rs @@ -2,7 +2,7 @@ use crate::{ config::FeatureFlags, entity::{Account, Aggregator, AggregatorColumn, Aggregators, NewAggregator, UpdateAggregator}, handler::extract::{extract_entity, Json}, - Crypter, Db, Error, Permissions, PermissionsActor, + AdminPermissionsActor, Crypter, Db, Error, Permissions, PermissionsActor, }; use axum::extract::{FromRef, FromRequestParts, State}; use axum::http::{request::Parts, StatusCode}; @@ -11,29 +11,7 @@ use sea_orm::{ sea_query::{all, any}, ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, }; -use trillium::Conn; -use trillium_api::FromConn; use trillium_client::Client; -use trillium_router::RouterConnExt; -use uuid::Uuid; - -#[trillium::async_trait] -impl FromConn for Aggregator { - async fn from_conn(conn: &mut Conn) -> Option { - let actor = PermissionsActor::from_conn(conn).await?; - let db: &Db = conn.state()?; - let id = conn.param("aggregator_id")?.parse::().ok()?; - let aggregator = Aggregators::find_by_id(id).one(db).await; - match aggregator { - Ok(Some(aggregator)) => actor.if_allowed(conn.method(), aggregator), - Ok(None) => None, - Err(error) => { - conn.insert_state(Error::from(error)); - None - } - } - } -} impl FromRequestParts for Aggregator where @@ -147,17 +125,13 @@ pub mod axum_handler { } pub async fn admin_create( - actor: PermissionsActor, + _admin: AdminPermissionsActor, State(db): State, State(client): State, State(crypter): State, State(feature_flags): State, Json(new_aggregator): Json, ) -> Result { - if !actor.is_admin() { - return Err(Error::NotFound); - } - let aggregator = new_aggregator .build( None, diff --git a/src/routes/api_tokens.rs b/src/routes/api_tokens.rs index cd97e0700..7cff60b4a 100644 --- a/src/routes/api_tokens.rs +++ b/src/routes/api_tokens.rs @@ -8,28 +8,7 @@ use axum::{ http::{request::Parts, StatusCode}, response::IntoResponse, }; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder}; -use trillium::Conn; -use trillium_api::FromConn; -use trillium_router::RouterConnExt; -use uuid::Uuid; - -#[trillium::async_trait] -impl FromConn for ApiToken { - async fn from_conn(conn: &mut Conn) -> Option { - let actor = PermissionsActor::from_conn(conn).await?; - let id = conn.param("api_token_id")?.parse::().ok()?; - let db: &Db = conn.state()?; - match ApiTokens::find_by_id(id).one(db).await { - Ok(Some(api_token)) => actor.if_allowed(conn.method(), api_token), - Ok(None) => None, - Err(error) => { - conn.insert_state(Error::from(error)); - None - } - } - } -} +use sea_orm::{ActiveModelTrait, ColumnTrait, ModelTrait, QueryFilter, QueryOrder}; impl FromRequestParts for ApiToken where diff --git a/src/routes/collector_credentials.rs b/src/routes/collector_credentials.rs index d4c24fcb8..7ae87a30e 100644 --- a/src/routes/collector_credentials.rs +++ b/src/routes/collector_credentials.rs @@ -12,31 +12,7 @@ use axum::{ response::IntoResponse, }; use httpdate::fmt_http_date; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; -use trillium::Conn; -use trillium_api::FromConn; -use trillium_router::RouterConnExt; -use uuid::Uuid; - -#[trillium::async_trait] -impl FromConn for CollectorCredential { - async fn from_conn(conn: &mut Conn) -> Option { - let actor = PermissionsActor::from_conn(conn).await?; - let id = conn - .param("collector_credential_id")? - .parse::() - .ok()?; - let db: &Db = conn.state()?; - match CollectorCredentials::find_by_id(id).one(db).await { - Ok(Some(collector_credential)) => actor.if_allowed(conn.method(), collector_credential), - Ok(None) => None, - Err(error) => { - conn.insert_state(Error::from(error)); - None - } - } - } -} +use sea_orm::{ActiveModelTrait, ColumnTrait, ModelTrait, QueryFilter}; impl FromRequestParts for CollectorCredential where diff --git a/src/routes/tasks.rs b/src/routes/tasks.rs index b8262036b..121da1908 100644 --- a/src/routes/tasks.rs +++ b/src/routes/tasks.rs @@ -19,10 +19,7 @@ use std::time::Duration; use time::OffsetDateTime; use tokio::join; use tracing::warn; -use trillium::Conn; -use trillium_api::FromConn; use trillium_client::Client; -use trillium_router::RouterConnExt; impl Permissions for Task { fn allow_write(&self, actor: &PermissionsActor) -> bool { @@ -30,24 +27,6 @@ impl Permissions for Task { } } -#[trillium::async_trait] -impl FromConn for Task { - async fn from_conn(conn: &mut Conn) -> Option { - let actor = PermissionsActor::from_conn(conn).await?; - let db: &Db = conn.state()?; - let id = conn.param("task_id")?; - - match Tasks::find_by_id(id).one(db).await { - Ok(Some(task)) => actor.if_allowed(conn.method(), task), - Ok(None) => None, - Err(error) => { - conn.insert_state(Error::from(error)); - None - } - } - } -} - impl FromRequestParts for Task where Db: FromRef, From 06d9c50cf24b97358d407d7ad459963fe600254f Mon Sep 17 00:00:00 2001 From: "J.C. Jones" Date: Fri, 8 May 2026 09:08:59 -0700 Subject: [PATCH 2/2] Fix Docker dev compose The Trillium `api()` handler chain had `state(User::for_integration_testing())` gated on `#[cfg(feature = "integration-testing")]`, which injected an admin user into every request. When 7C removed the Trillium router, this injection was lost, causing the `pair_aggregator` container in compose.dev.yaml to get 403 Forbidden on `POST /api/aggregators` (n.b. the CLI uses `--token=""` and relies on the integration-testing user for auth). So, let's fix it. This is a new Axum middleware that unconditionally injects the `integration-testing` user into request extensions when the feature is enabled. While I'm at it, I also rename the existing `test-header-injection` middleware to `inject_test_header_user` in order to distinguish it from the unconditional injection. --- Cargo.toml | 7 +++---- src/handler.rs | 32 ++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a366162d8..e10aab0c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,10 +29,9 @@ default = [] api-mocks = ["dep:trillium-testing"] integration-testing = [] # Enables a non-production axum middleware that reads an `X-Integration-Testing-User` -# header and injects the decoded [`User`] into request extensions. Strictly for -# use by the test harness (`test-support`); never enable in deployed builds. -# TODO: fold into `integration-testing` once Trillium is removed — the blanket -# User injection in the Trillium api() handler currently makes them incompatible. +# header and injects the decoded [`User`] into request extensions. Used by +# `test-support` to impersonate specific users in tests; never enable in deployed +# builds. TODO(Part 9): fold into `integration-testing` when test-support is rewritten. test-header-injection = [] otlp-trace = ["opentelemetry/trace", "opentelemetry-otlp", "opentelemetry_sdk/trace", "trillium-opentelemetry/trace"] diff --git a/src/handler.rs b/src/handler.rs index f02de3a54..1f1a37201 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -150,10 +150,13 @@ impl DivviupApi { .layer(axum_cors_layer(&config)) .layer(axum_session_layer(db.clone(), &config.session_secrets)); - #[cfg(feature = "test-header-injection")] + #[cfg(feature = "integration-testing")] let middleware = middleware.layer(axum::middleware::from_fn(inject_integration_testing_user)); + #[cfg(feature = "test-header-injection")] + let middleware = middleware.layer(axum::middleware::from_fn(inject_test_header_user)); + let axum_router = axum::Router::new() // Temporary test endpoint to verify the proxy bridge works. // TODO: Remove once enough routes have migrated to make it redundant. @@ -244,15 +247,32 @@ async fn inject_test_user_trillium(mut conn: trillium::Conn) -> trillium::Conn { conn } -/// Axum-side test shim: if the request carries an `X-Integration-Testing-User` -/// header with a JSON-encoded [`crate::User`], place the user in request -/// extensions so [`crate::User`]'s extractor picks it up without a real -/// session. +/// Axum middleware that unconditionally injects an admin +/// [`User`](crate::User) into every request. This is the Axum equivalent of +/// the Trillium `state(User::for_integration_testing())` that was in the old +/// `api()` handler chain. +/// +/// Only compiled under `--features integration-testing` (enabled by +/// `compose.dev.override.yaml`). Never compiled into deployed builds. +#[cfg(feature = "integration-testing")] +async fn inject_integration_testing_user( + mut request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + request + .extensions_mut() + .insert(crate::User::for_integration_testing()); + next.run(request).await +} + +/// Axum middleware that reads an `X-Integration-Testing-User` header and +/// injects the decoded [`User`](crate::User) into request extensions. +/// Used by `test-support` to impersonate specific users in tests. /// /// Only compiled under `--features test-header-injection` (enabled by /// `test-support`). Never compiled into deployed builds. #[cfg(feature = "test-header-injection")] -async fn inject_integration_testing_user( +async fn inject_test_header_user( mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response {