Skip to content

Commit e17be44

Browse files
authored
feat(perms): Builder permissioning Tower layer (#46)
Closes ENG-1165
1 parent 1f46724 commit e17be44

File tree

3 files changed

+230
-1
lines changed

3 files changed

+230
-1
lines changed

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ alloy = { version = "=1.0.11", optional = true, default-features = false, featur
4949
serde = { version = "1", features = ["derive"] }
5050
async-trait = { version = "0.1.80", optional = true }
5151
eyre = { version = "0.6.12", optional = true }
52+
axum = { version = "0.8.1", optional = true }
53+
tower = { version = "0.5.2", optional = true }
5254

5355
# AWS
5456
aws-config = { version = "1.1.7", optional = true }
@@ -66,7 +68,7 @@ tokio = { version = "1.43.0", features = ["macros"] }
6668
[features]
6769
default = ["alloy"]
6870
alloy = ["dep:alloy", "dep:async-trait", "dep:aws-config", "dep:aws-sdk-kms"]
69-
perms = ["dep:oauth2", "dep:tokio", "dep:reqwest", "dep:signet-tx-cache", "dep:eyre"]
71+
perms = ["dep:oauth2", "dep:tokio", "dep:reqwest", "dep:signet-tx-cache", "dep:eyre", "dep:axum", "dep:tower"]
7072

7173
[[example]]
7274
name = "oauth"

src/perms/middleware.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
//! Middleware to check if a builder is allowed to sign a block.
2+
//! Implemented as a [`tower::Layer`] and [`tower::Service`],
3+
//! which can be used in an Axum application to enforce builder permissions
4+
//! based on the current slot and builder configuration.
5+
6+
use crate::perms::Builders;
7+
use axum::{
8+
extract::Request,
9+
http::{HeaderValue, StatusCode},
10+
response::{IntoResponse, Response},
11+
Json,
12+
};
13+
use core::fmt;
14+
use serde::Serialize;
15+
use std::{future::Future, pin::Pin, sync::Arc};
16+
use tower::{Layer, Service};
17+
use tracing::info;
18+
19+
/// Possible API error responses when a builder permissioning check fails.
20+
#[derive(Serialize)]
21+
struct ApiError {
22+
/// The error itself.
23+
error: &'static str,
24+
/// A human-readable message describing the error.
25+
message: &'static str,
26+
/// A human-readable hint for the error, if applicable.
27+
#[serde(skip_serializing_if = "Option::is_none")]
28+
hint: Option<&'static str>,
29+
}
30+
31+
impl ApiError {
32+
/// API error for missing authentication header.
33+
const fn missing_header() -> (StatusCode, Json<ApiError>) {
34+
(
35+
StatusCode::UNAUTHORIZED,
36+
Json(ApiError {
37+
error: "MISSING_AUTH_HEADER",
38+
message: "Missing authentication header",
39+
hint: Some("Please provide the 'x-jwt-claim-sub' header with your JWT claim sub."),
40+
}),
41+
)
42+
}
43+
44+
const fn invalid_encoding() -> (StatusCode, Json<ApiError>) {
45+
(
46+
StatusCode::BAD_REQUEST,
47+
Json(ApiError {
48+
error: "INVALID_ENCODING",
49+
message: "Invalid encoding in header value",
50+
hint: Some("Ensure the 'x-jwt-claim-sub' header is properly encoded."),
51+
}),
52+
)
53+
}
54+
55+
const fn header_empty() -> (StatusCode, Json<ApiError>) {
56+
(
57+
StatusCode::BAD_REQUEST,
58+
Json(ApiError {
59+
error: "EMPTY_HEADER",
60+
message: "Empty header value",
61+
hint: Some("Ensure the 'x-jwt-claim-sub' header is not empty."),
62+
}),
63+
)
64+
}
65+
66+
/// API error for permission denied.
67+
const fn permission_denied(hint: Option<&'static str>) -> (StatusCode, Json<ApiError>) {
68+
(
69+
StatusCode::FORBIDDEN,
70+
Json(ApiError {
71+
error: "PERMISSION_DENIED",
72+
message: "Builder permission denied",
73+
hint,
74+
}),
75+
)
76+
}
77+
}
78+
79+
/// A middleware layer that can check if a builder is allowed to perform an action
80+
/// during the current request.
81+
///
82+
/// Contains a pointer to the [`Builders`] struct, which holds the configuration and
83+
/// builders for the permissioning system.
84+
#[derive(Clone)]
85+
pub struct BuilderPermissioningLayer {
86+
/// The configured builders.
87+
builders: Arc<Builders>,
88+
}
89+
90+
impl BuilderPermissioningLayer {
91+
/// Create a new `BuilderPermissioningLayer` with the given builders.
92+
pub const fn new(builders: Arc<Builders>) -> Self {
93+
Self { builders }
94+
}
95+
}
96+
97+
impl fmt::Debug for BuilderPermissioningLayer {
98+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99+
f.debug_struct("BuilderPermissioningLayer").finish()
100+
}
101+
}
102+
103+
impl<S> Layer<S> for BuilderPermissioningLayer {
104+
type Service = BuilderPermissioningService<S>;
105+
106+
fn layer(&self, inner: S) -> Self::Service {
107+
BuilderPermissioningService {
108+
inner,
109+
builders: self.builders.clone(),
110+
}
111+
}
112+
}
113+
114+
/// A service that checks if a builder is allowed to perform an action during the
115+
/// current request.
116+
///
117+
/// Contains a pointer to the [`Builders`] struct, which holds the configuration and
118+
/// builders for the permissioning system. Meant to be nestable and cheaply cloneable.
119+
#[derive(Clone)]
120+
pub struct BuilderPermissioningService<S> {
121+
inner: S,
122+
builders: Arc<Builders>,
123+
}
124+
125+
impl<S> BuilderPermissioningService<S> {
126+
/// Create a new `BuilderPermissioningService` with the given inner service and builders.
127+
pub const fn new(inner: S, builders: Arc<Builders>) -> Self {
128+
Self { inner, builders }
129+
}
130+
}
131+
132+
impl fmt::Debug for BuilderPermissioningService<()> {
133+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134+
f.debug_struct("BuilderPermissioningService").finish()
135+
}
136+
}
137+
138+
impl<S> Service<Request> for BuilderPermissioningService<S>
139+
where
140+
S: Service<Request, Response = Response> + Clone + Send + 'static,
141+
S::Future: Send + 'static,
142+
{
143+
type Response = Response;
144+
type Error = S::Error;
145+
type Future =
146+
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
147+
148+
fn poll_ready(
149+
&mut self,
150+
cx: &mut std::task::Context<'_>,
151+
) -> std::task::Poll<Result<(), Self::Error>> {
152+
self.inner.poll_ready(cx)
153+
}
154+
155+
fn call(&mut self, req: Request) -> Self::Future {
156+
let mut this = self.clone();
157+
158+
Box::pin(async move {
159+
let span = tracing::info_span!(
160+
"builder::permissioning",
161+
builder = tracing::field::Empty,
162+
permissioned_builder = this.builders.current_builder().sub(),
163+
current_slot = this.builders.calc().current_slot(),
164+
permissioning_error = tracing::field::Empty,
165+
);
166+
167+
info!("builder permissioning check started");
168+
169+
// Check if the sub is in the header.
170+
let sub = match validate_header_sub(req.headers().get("x-jwt-claim-sub")) {
171+
Ok(sub) => sub,
172+
Err(err) => {
173+
info!(api_err = %err.1.message, "permission denied");
174+
span.record("permissioning_error", err.1.message);
175+
return Ok(err.into_response());
176+
}
177+
};
178+
179+
if let Err(err) = this.builders.is_builder_permissioned(sub) {
180+
info!(api_err = %err, "permission denied");
181+
span.record("permissioning_error", err.to_string());
182+
183+
let hint = builder_permissioning_hint(&err);
184+
185+
return Ok(ApiError::permission_denied(hint).into_response());
186+
}
187+
188+
info!("builder permissioned successfully");
189+
190+
this.inner.call(req).await
191+
})
192+
}
193+
}
194+
195+
fn validate_header_sub(sub: Option<&HeaderValue>) -> Result<&str, (StatusCode, Json<ApiError>)> {
196+
let Some(sub) = sub else {
197+
return Err(ApiError::missing_header());
198+
};
199+
200+
let Some(sub) = sub.to_str().ok() else {
201+
return Err(ApiError::invalid_encoding());
202+
};
203+
204+
if sub.is_empty() {
205+
return Err(ApiError::header_empty());
206+
}
207+
208+
Ok(sub)
209+
}
210+
211+
const fn builder_permissioning_hint(
212+
err: &crate::perms::BuilderPermissionError,
213+
) -> Option<&'static str> {
214+
match err {
215+
crate::perms::BuilderPermissionError::ActionAttemptTooEarly => {
216+
Some("Action attempted too early in the slot.")
217+
}
218+
crate::perms::BuilderPermissionError::ActionAttemptTooLate => {
219+
Some("Action attempted too late in the slot.")
220+
}
221+
crate::perms::BuilderPermissionError::NotPermissioned => {
222+
Some("Builder is not permissioned for this slot.")
223+
}
224+
}
225+
}

src/perms/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pub use config::{SlotAuthzConfig, SlotAuthzConfigEnvError};
77
pub(crate) mod oauth;
88
pub use oauth::{Authenticator, OAuthConfig, SharedToken};
99

10+
pub mod middleware;
11+
1012
/// Contains [`BuilderTxCache`] client and related types for interacting with
1113
/// the transaction cache.
1214
///

0 commit comments

Comments
 (0)