From 0cd82cb3b388e08ac3b4fafc2294684544e4a2e6 Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 17:39:20 +0200 Subject: [PATCH 1/7] feat(perms): Builder permissioning `Tower` layer --- Cargo.toml | 4 +- src/lib.rs | 2 +- src/perms/middleware.rs | 113 ++++++++++++++++++++++++++++++++++++++++ src/perms/mod.rs | 2 + 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/perms/middleware.rs diff --git a/Cargo.toml b/Cargo.toml index ba5111f..1781805 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ alloy = { version = "=1.0.11", optional = true, default-features = false, featur serde = { version = "1", features = ["derive"] } async-trait = { version = "0.1.80", optional = true } eyre = { version = "0.6.12", optional = true } +axum = { version = "0.8.1", optional = true } +tower = { version = "0.5.2", optional = true } # AWS aws-config = { version = "1.1.7", optional = true } @@ -66,7 +68,7 @@ tokio = { version = "1.43.0", features = ["macros"] } [features] default = ["alloy"] alloy = ["dep:alloy", "dep:async-trait", "dep:aws-config", "dep:aws-sdk-kms"] -perms = ["dep:oauth2", "dep:tokio", "dep:reqwest", "dep:signet-tx-cache", "dep:eyre"] +perms = ["dep:oauth2", "dep:tokio", "dep:reqwest", "dep:signet-tx-cache", "dep:eyre", "dep:axum", "dep:tower"] [[example]] name = "oauth" diff --git a/src/lib.rs b/src/lib.rs index c85a41b..e85403a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ #![deny(unused_must_use, rust_2018_idioms)] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -#[cfg(feature = "perms")] +// #[cfg(feature = "perms")] /// Permissioning and authorization utilities for Signet builders. pub mod perms; diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs new file mode 100644 index 0000000..57f135a --- /dev/null +++ b/src/perms/middleware.rs @@ -0,0 +1,113 @@ +//! Middleware to check if a builder is allowed to sign a block. + +use crate::perms::Builders; +use axum::{ + extract::Request, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use core::fmt; +use std::{future::Future, pin::Pin, sync::Arc}; +use tower::{Layer, Service}; + +/// A middleware layer that can check if a builder is allowed to perform an action +/// during the current request. +/// +/// Contains a pointer to the [`Builders`] struct, which holds the configuration and +/// builders for the permissioning system. +#[derive(Clone)] +pub struct BuilderPermissioningLayer { + /// The configured builders. + builders: Arc, +} + +impl BuilderPermissioningLayer { + /// Create a new `BuilderPermissioningLayer` with the given builders. + pub const fn new(builders: Arc) -> Self { + Self { builders } + } +} + +impl fmt::Debug for BuilderPermissioningLayer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BuilderPermissioningLayer").finish() + } +} + +impl Layer for BuilderPermissioningLayer { + type Service = BuilderPermissioningService; + + fn layer(&self, inner: S) -> Self::Service { + BuilderPermissioningService { + inner, + builders: self.builders.clone(), + } + } +} + +/// A service that checks if a builder is allowed to perform an action during the +/// current request. +/// +/// Contains a pointer to the [`Builders`] struct, which holds the configuration and +/// builders for the permissioning system. Meant to be nestable and cheaply cloneable. +#[derive(Clone)] +pub struct BuilderPermissioningService { + inner: S, + builders: Arc, +} + +impl BuilderPermissioningService { + /// Create a new `BuilderPermissioningService` with the given inner service and builders. + pub const fn new(inner: S, builders: Arc) -> Self { + Self { inner, builders } + } +} + +impl fmt::Debug for BuilderPermissioningService<()> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BuilderPermissioningService").finish() + } +} + +impl Service for BuilderPermissioningService +where + S: Service + Clone + Send + 'static, + S::Future: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = + Pin> + Send + 'static>>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let mut this = self.clone(); + + Box::pin(async move { + // Check if the sub is in the header. + let sub = if let Some(sub) = req.headers().get("x-jwt-claim-sub") { + // If so, attempt to convert it to a string. + match sub.to_str() { + Ok(sub) => sub, + Err(_) => { + return Ok((StatusCode::BAD_REQUEST, "invalid header").into_response()); + } + } + } else { + return Ok((StatusCode::UNAUTHORIZED, "missing sub header").into_response()); + }; + + if let Err(err) = this.builders.is_builder_permissioned(sub) { + return Ok((StatusCode::FORBIDDEN, err.to_string()).into_response()); + } + + this.inner.call(req).await + }) + } +} diff --git a/src/perms/mod.rs b/src/perms/mod.rs index fb9193b..2c1a5b4 100644 --- a/src/perms/mod.rs +++ b/src/perms/mod.rs @@ -7,6 +7,8 @@ pub use config::{SlotAuthzConfig, SlotAuthzConfigEnvError}; pub(crate) mod oauth; pub use oauth::{Authenticator, OAuthConfig, SharedToken}; +pub mod middleware; + /// Contains [`BuilderTxCache`] client and related types for interacting with /// the transaction cache. /// From 8f888b1a9fd3523760931b23840f255b1401519f Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 19:09:00 +0200 Subject: [PATCH 2/7] chore: tracing --- src/perms/middleware.rs | 80 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs index 57f135a..047024f 100644 --- a/src/perms/middleware.rs +++ b/src/perms/middleware.rs @@ -5,10 +5,57 @@ use axum::{ extract::Request, http::StatusCode, response::{IntoResponse, Response}, + Json, }; use core::fmt; +use serde::Serialize; use std::{future::Future, pin::Pin, sync::Arc}; use tower::{Layer, Service}; +use tracing::{error, info}; + +/// Possible API error responses when a builder permissioning check fails. +#[derive(Serialize)] +struct ApiError { + /// The error itself. + error: &'static str, + /// A human-readable message describing the error. + message: &'static str, +} + +impl ApiError { + /// API error for missing authentication header. + const fn missing_header() -> (StatusCode, Json) { + ( + StatusCode::UNAUTHORIZED, + Json(ApiError { + error: "MISSING_AUTH_HEADER", + message: "Missing authentication header", + }), + ) + } + + /// API error for invalid header encoding. + const fn invalid_encoding() -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "INVALID_HEADER_ENCODING", + message: "Invalid header encoding", + }), + ) + } + + /// API error for permission denied. + const fn permission_denied() -> (StatusCode, Json) { + ( + StatusCode::FORBIDDEN, + Json(ApiError { + error: "PERMISSION_DENIED", + message: "Builder permission denied", + }), + ) + } +} /// A middleware layer that can check if a builder is allowed to perform an action /// during the current request. @@ -90,23 +137,40 @@ where let mut this = self.clone(); Box::pin(async move { + let span = tracing::info_span!( + "builder::permissioning", + builder = tracing::field::Empty, + permissioned_builder = this.builders.current_builder().sub(), + current_slot = this.builders.calc().current_slot(), + ); + + info!("builder permissioning check started"); + // Check if the sub is in the header. - let sub = if let Some(sub) = req.headers().get("x-jwt-claim-sub") { - // If so, attempt to convert it to a string. - match sub.to_str() { - Ok(sub) => sub, + let sub = match req.headers().get("x-jwt-claim-sub") { + Some(header_value) => match header_value.to_str() { + Ok(sub) => { + span.record("builder", sub); + sub + } Err(_) => { - return Ok((StatusCode::BAD_REQUEST, "invalid header").into_response()); + error!("builder request has invalid header encoding"); + return Ok(ApiError::invalid_encoding().into_response()); } + }, + None => { + error!("builder request missing header"); + return Ok(ApiError::missing_header().into_response()); } - } else { - return Ok((StatusCode::UNAUTHORIZED, "missing sub header").into_response()); }; if let Err(err) = this.builders.is_builder_permissioned(sub) { - return Ok((StatusCode::FORBIDDEN, err.to_string()).into_response()); + info!(%err, %sub, "permission denied"); + return Ok(ApiError::permission_denied().into_response()); } + info!(%sub, current_slot = %this.builders.calc().current_slot(), "builder permissioned successfully"); + this.inner.call(req).await }) } From 48d290bf645144fbed739d54990922a9f8e0bc99 Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 21:45:47 +0200 Subject: [PATCH 3/7] chore: turn logs into infos, record error span fields --- src/perms/middleware.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs index 047024f..f1f6557 100644 --- a/src/perms/middleware.rs +++ b/src/perms/middleware.rs @@ -11,7 +11,7 @@ use core::fmt; use serde::Serialize; use std::{future::Future, pin::Pin, sync::Arc}; use tower::{Layer, Service}; -use tracing::{error, info}; +use tracing::info; /// Possible API error responses when a builder permissioning check fails. #[derive(Serialize)] @@ -142,6 +142,7 @@ where builder = tracing::field::Empty, permissioned_builder = this.builders.current_builder().sub(), current_slot = this.builders.calc().current_slot(), + permissioning_error = tracing::field::Empty, ); info!("builder permissioning check started"); @@ -153,19 +154,29 @@ where span.record("builder", sub); sub } - Err(_) => { - error!("builder request has invalid header encoding"); - return Ok(ApiError::invalid_encoding().into_response()); + Err(err) => { + let api_err = ApiError::invalid_encoding(); + + info!(api_err = %api_err.1.message, header_err = %err, "permission denied"); + span.record("permissioning_error", api_err.1.message); + + return Ok(api_err.into_response()); } }, None => { - error!("builder request missing header"); - return Ok(ApiError::missing_header().into_response()); + let api_err = ApiError::missing_header(); + + info!(api_err = %api_err.1.message, "permission denied"); + span.record("permissioning_error", api_err.1.message); + + return Ok(api_err.into_response()); } }; if let Err(err) = this.builders.is_builder_permissioned(sub) { - info!(%err, %sub, "permission denied"); + info!(api_err = %err, %sub, "permission denied"); + span.record("permissioning_error", err.to_string()); + return Ok(ApiError::permission_denied().into_response()); } From 91c4c4e8e2d9d87484dbee93d07766b60506a292 Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 21:47:06 +0200 Subject: [PATCH 4/7] chore: remove sub(way) --- src/perms/middleware.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs index f1f6557..1a258bd 100644 --- a/src/perms/middleware.rs +++ b/src/perms/middleware.rs @@ -174,13 +174,13 @@ where }; if let Err(err) = this.builders.is_builder_permissioned(sub) { - info!(api_err = %err, %sub, "permission denied"); + info!(api_err = %err, "permission denied"); span.record("permissioning_error", err.to_string()); return Ok(ApiError::permission_denied().into_response()); } - info!(%sub, current_slot = %this.builders.calc().current_slot(), "builder permissioned successfully"); + info!(current_slot = %this.builders.calc().current_slot(), "builder permissioned successfully"); this.inner.call(req).await }) From 72f4b21e736a63eec50d32d2a879801caa26bb1a Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 22:01:17 +0200 Subject: [PATCH 5/7] chore: add permissioning hints --- src/perms/middleware.rs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs index 1a258bd..92494bb 100644 --- a/src/perms/middleware.rs +++ b/src/perms/middleware.rs @@ -20,6 +20,9 @@ struct ApiError { error: &'static str, /// A human-readable message describing the error. message: &'static str, + /// A human-readable hint for the error, if applicable. + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option<&'static str>, } impl ApiError { @@ -30,6 +33,7 @@ impl ApiError { Json(ApiError { error: "MISSING_AUTH_HEADER", message: "Missing authentication header", + hint: Some("Please provide the 'x-jwt-claim-sub' header with your JWT claim sub."), }), ) } @@ -41,17 +45,21 @@ impl ApiError { Json(ApiError { error: "INVALID_HEADER_ENCODING", message: "Invalid header encoding", + hint: Some( + "Ensure the 'x-jwt-claim-sub' header is properly encoded as a UTF-8 string.", + ), }), ) } /// API error for permission denied. - const fn permission_denied() -> (StatusCode, Json) { + const fn permission_denied(hint: Option<&'static str>) -> (StatusCode, Json) { ( StatusCode::FORBIDDEN, Json(ApiError { error: "PERMISSION_DENIED", message: "Builder permission denied", + hint, }), ) } @@ -177,7 +185,9 @@ where info!(api_err = %err, "permission denied"); span.record("permissioning_error", err.to_string()); - return Ok(ApiError::permission_denied().into_response()); + let hint = builder_permissioning_hint(&err); + + return Ok(ApiError::permission_denied(hint).into_response()); } info!(current_slot = %this.builders.calc().current_slot(), "builder permissioned successfully"); @@ -186,3 +196,19 @@ where }) } } + +const fn builder_permissioning_hint( + err: &crate::perms::BuilderPermissionError, +) -> Option<&'static str> { + match err { + crate::perms::BuilderPermissionError::ActionAttemptTooEarly => { + Some("Action attempted too early in the slot.") + } + crate::perms::BuilderPermissionError::ActionAttemptTooLate => { + Some("Action attempted too late in the slot.") + } + crate::perms::BuilderPermissionError::NotPermissioned => { + Some("Builder is not permissioned for this slot.") + } + } +} From bdb5b3cb48d1e2c3010f35a63cfacb98a2b852c1 Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 22:17:02 +0200 Subject: [PATCH 6/7] chore: refactor --- src/perms/middleware.rs | 72 +++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs index 92494bb..8771846 100644 --- a/src/perms/middleware.rs +++ b/src/perms/middleware.rs @@ -3,7 +3,7 @@ use crate::perms::Builders; use axum::{ extract::Request, - http::StatusCode, + http::{HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -27,7 +27,7 @@ struct ApiError { impl ApiError { /// API error for missing authentication header. - const fn missing_header() -> (StatusCode, Json) { + const fn missing_header() -> (StatusCode, Json) { ( StatusCode::UNAUTHORIZED, Json(ApiError { @@ -38,22 +38,30 @@ impl ApiError { ) } - /// API error for invalid header encoding. - const fn invalid_encoding() -> (StatusCode, Json) { + const fn invalid_encoding() -> (StatusCode, Json) { ( StatusCode::BAD_REQUEST, Json(ApiError { - error: "INVALID_HEADER_ENCODING", - message: "Invalid header encoding", - hint: Some( - "Ensure the 'x-jwt-claim-sub' header is properly encoded as a UTF-8 string.", - ), + error: "INVALID_ENCODING", + message: "Invalid encoding in header value", + hint: Some("Ensure the 'x-jwt-claim-sub' header is properly encoded."), + }), + ) + } + + const fn header_empty() -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "EMPTY_HEADER", + message: "Empty header value", + hint: Some("Ensure the 'x-jwt-claim-sub' header is not empty."), }), ) } /// API error for permission denied. - const fn permission_denied(hint: Option<&'static str>) -> (StatusCode, Json) { + const fn permission_denied(hint: Option<&'static str>) -> (StatusCode, Json) { ( StatusCode::FORBIDDEN, Json(ApiError { @@ -156,28 +164,12 @@ where info!("builder permissioning check started"); // Check if the sub is in the header. - let sub = match req.headers().get("x-jwt-claim-sub") { - Some(header_value) => match header_value.to_str() { - Ok(sub) => { - span.record("builder", sub); - sub - } - Err(err) => { - let api_err = ApiError::invalid_encoding(); - - info!(api_err = %api_err.1.message, header_err = %err, "permission denied"); - span.record("permissioning_error", api_err.1.message); - - return Ok(api_err.into_response()); - } - }, - None => { - let api_err = ApiError::missing_header(); - - info!(api_err = %api_err.1.message, "permission denied"); - span.record("permissioning_error", api_err.1.message); - - return Ok(api_err.into_response()); + let sub = match validate_header_sub(req.headers().get("x-jwt-claim-sub")) { + Ok(sub) => sub, + Err(err) => { + info!(api_err = %err.1.message, "permission denied"); + span.record("permissioning_error", err.1.message); + return Ok(err.into_response()); } }; @@ -197,6 +189,22 @@ where } } +fn validate_header_sub(sub: Option<&HeaderValue>) -> Result<&str, (StatusCode, Json)> { + let Some(sub) = sub else { + return Err(ApiError::missing_header()); + }; + + let Some(sub) = sub.to_str().ok() else { + return Err(ApiError::invalid_encoding()); + }; + + if sub.is_empty() { + return Err(ApiError::header_empty()); + } + + Ok(sub) +} + const fn builder_permissioning_hint( err: &crate::perms::BuilderPermissionError, ) -> Option<&'static str> { From 0dcec62fb803b48feb5fdd2d2999e8b5acd3d655 Mon Sep 17 00:00:00 2001 From: evalir Date: Tue, 1 Jul 2025 22:24:55 +0200 Subject: [PATCH 7/7] chore: do not include slot --- src/lib.rs | 2 +- src/perms/middleware.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e85403a..c85a41b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ #![deny(unused_must_use, rust_2018_idioms)] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -// #[cfg(feature = "perms")] +#[cfg(feature = "perms")] /// Permissioning and authorization utilities for Signet builders. pub mod perms; diff --git a/src/perms/middleware.rs b/src/perms/middleware.rs index 8771846..bc61291 100644 --- a/src/perms/middleware.rs +++ b/src/perms/middleware.rs @@ -1,4 +1,7 @@ //! Middleware to check if a builder is allowed to sign a block. +//! Implemented as a [`tower::Layer`] and [`tower::Service`], +//! which can be used in an Axum application to enforce builder permissions +//! based on the current slot and builder configuration. use crate::perms::Builders; use axum::{ @@ -182,7 +185,7 @@ where return Ok(ApiError::permission_denied(hint).into_response()); } - info!(current_slot = %this.builders.calc().current_slot(), "builder permissioned successfully"); + info!("builder permissioned successfully"); this.inner.call(req).await })