Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
3 changes: 3 additions & 0 deletions src/entity/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
108 changes: 40 additions & 68 deletions src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
}
Expand Down Expand Up @@ -160,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.
Expand All @@ -175,7 +168,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))
Expand Down Expand Up @@ -203,7 +196,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,
)),
Expand Down Expand Up @@ -237,23 +231,13 @@ impl AsRef<Db> 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::<crate::User>(v).ok())
{
conn.insert_state(user);
} else if let Some(json) = conn
if let Some(json) = conn
.state::<crate::User>()
.and_then(|u| serde_json::to_string(u).ok())
{
Expand All @@ -263,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 {
Expand All @@ -296,32 +297,3 @@ impl<H: Handler> NamedHandler<H> {
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)),
),
)
}
1 change: 1 addition & 0 deletions src/handler/cors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 1 addition & 3 deletions src/handler/custom_mime_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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;

Expand All @@ -62,7 +61,6 @@ impl<S> Layer<S> for ReplaceMimeTypesLayer {
}

/// Tower [`Service`] produced by [`ReplaceMimeTypesLayer`].
#[cfg_attr(not(test), expect(dead_code))] // Constructed by ReplaceMimeTypesLayer.
#[derive(Clone, Debug)]
pub struct ReplaceMimeTypesService<S> {
inner: S,
Expand Down
21 changes: 0 additions & 21 deletions src/handler/misc.rs

This file was deleted.

2 changes: 2 additions & 0 deletions src/handler/session_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 1 addition & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
24 changes: 24 additions & 0 deletions src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<S> FromRequestParts<S> for AdminPermissionsActor
where
Db: FromRef<S>,
S: Send + Sync,
{
type Rejection = Error;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Error> {
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)
Expand Down
Loading
Loading