Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
177 changes: 177 additions & 0 deletions src/perms/middleware.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! 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},
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<Self>) {
(
StatusCode::UNAUTHORIZED,
Json(ApiError {
error: "MISSING_AUTH_HEADER",
message: "Missing authentication header",
}),
)
}

/// API error for invalid header encoding.
const fn invalid_encoding() -> (StatusCode, Json<Self>) {
(
StatusCode::BAD_REQUEST,
Json(ApiError {
error: "INVALID_HEADER_ENCODING",
message: "Invalid header encoding",
Copy link
Member

@prestwich prestwich Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some Hint: text in the human-readable strings would be great, re: expectations., what header is missing/invalid, and what does a valid one look like?

for next one,
why was permission denied?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are user facing API errors. I thought we wanted to be opaque here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we dont want to leak server internals by returning unmodified error objects that may contain things like stack traces. we do want to make them usefuk and helpful

}),
)
}

/// API error for permission denied.
const fn permission_denied() -> (StatusCode, Json<Self>) {
(
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.
///
/// 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<Builders>,
}

impl BuilderPermissioningLayer {
/// Create a new `BuilderPermissioningLayer` with the given builders.
pub const fn new(builders: Arc<Builders>) -> Self {
Self { builders }
}
}

impl fmt::Debug for BuilderPermissioningLayer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BuilderPermissioningLayer").finish()
}
}

impl<S> Layer<S> for BuilderPermissioningLayer {
type Service = BuilderPermissioningService<S>;

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<S> {
inner: S,
builders: Arc<Builders>,
}

impl<S> BuilderPermissioningService<S> {
/// Create a new `BuilderPermissioningService` with the given inner service and builders.
pub const fn new(inner: S, builders: Arc<Builders>) -> Self {
Self { inner, builders }
}
}

impl fmt::Debug for BuilderPermissioningService<()> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BuilderPermissioningService").finish()
}
}

impl<S> Service<Request> for BuilderPermissioningService<S>
where
S: Service<Request, Response = Response> + Clone + Send + 'static,
S::Future: Send + 'static,
{
type Response = Response;
type Error = S::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;

fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}

fn call(&mut self, req: Request) -> Self::Future {
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 = match req.headers().get("x-jwt-claim-sub") {
Some(header_value) => match header_value.to_str() {
Ok(sub) => {
span.record("builder", sub);
sub
}
Err(_) => {
error!("builder request has invalid header encoding");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is probably not error, as it is not a problem with our system

return Ok(ApiError::invalid_encoding().into_response());
}
},
None => {
error!("builder request missing header");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is probably not error, as it is not a problem with our system

return Ok(ApiError::missing_header().into_response());
}
};

if let Err(err) = this.builders.is_builder_permissioned(sub) {
info!(%err, %sub, "permission denied");
return Ok(ApiError::permission_denied().into_response());
}

info!(%sub, current_slot = %this.builders.calc().current_slot(), "builder permissioned successfully");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same questions


this.inner.call(req).await
})
}
}
2 changes: 2 additions & 0 deletions src/perms/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
Loading