Skip to content

Commit 7bb7442

Browse files
authored
Admin improvements (#812)
* security and improvements * rate limiting admin endpoints * token in memory fix * delete returns 404 if not found * health behind auth * admin warning if no token configured * cargo fmt * improvements
1 parent b126a68 commit 7bb7442

File tree

7 files changed

+145
-36
lines changed

7 files changed

+145
-36
lines changed

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/server/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ bigdecimal = "0.4.9"
3232
chrono = "0.4.42"
3333
thiserror = "1.0"
3434
tokio-util = "0.7"
35+
subtle = "2.5"
36+
tracing.workspace = true
37+
governor = "0.6"
38+
secrecy = "0.8"
3539

3640
[[bin]]
3741
name = "deepbook-server"

crates/server/src/admin/auth.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,23 @@ pub async fn require_admin_auth(
2929
if state.is_valid_admin_token(token) {
3030
Ok(next.run(req).await)
3131
} else {
32-
Err(StatusCode::UNAUTHORIZED)
32+
tracing::warn!("Invalid admin token provided");
33+
auth_failure_rate_limited(&state)
3334
}
3435
}
35-
_ => Err(StatusCode::UNAUTHORIZED),
36+
_ => {
37+
tracing::warn!("Missing or malformed admin authorization header");
38+
auth_failure_rate_limited(&state)
39+
}
40+
}
41+
}
42+
43+
/// Returns 429 if rate limit exceeded, otherwise 401
44+
fn auth_failure_rate_limited(state: &AppState) -> Result<Response, StatusCode> {
45+
if !state.check_admin_rate_limit() {
46+
tracing::warn!("Admin auth rate limit exceeded");
47+
Err(StatusCode::TOO_MANY_REQUESTS)
48+
} else {
49+
Err(StatusCode::UNAUTHORIZED)
3650
}
3751
}

crates/server/src/admin/handlers.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ pub async fn create_pool(
6161
State(state): State<Arc<AppState>>,
6262
Json(payload): Json<CreatePoolRequest>,
6363
) -> Result<Json<AdminResponse>, DeepBookError> {
64+
tracing::info!(
65+
action = "create_pool",
66+
pool_id = %payload.pool_id,
67+
pool_name = %payload.pool_name,
68+
base_asset = %payload.base_asset_symbol,
69+
quote_asset = %payload.quote_asset_symbol,
70+
"Admin creating pool"
71+
);
6472
state.writer().create_pool(payload).await?;
6573
Ok(Json(AdminResponse {
6674
status: "created".to_string(),
@@ -72,6 +80,15 @@ pub async fn update_pool(
7280
Path(pool_id): Path<String>,
7381
Json(payload): Json<UpdatePoolRequest>,
7482
) -> Result<Json<AdminResponse>, DeepBookError> {
83+
tracing::info!(
84+
action = "update_pool",
85+
pool_id = %pool_id,
86+
pool_name = ?payload.pool_name,
87+
min_size = ?payload.min_size,
88+
lot_size = ?payload.lot_size,
89+
tick_size = ?payload.tick_size,
90+
"Admin updating pool"
91+
);
7592
state.writer().update_pool(&pool_id, payload).await?;
7693
Ok(Json(AdminResponse {
7794
status: "updated".to_string(),
@@ -82,6 +99,11 @@ pub async fn delete_pool(
8299
State(state): State<Arc<AppState>>,
83100
Path(pool_id): Path<String>,
84101
) -> Result<Json<AdminResponse>, DeepBookError> {
102+
tracing::info!(
103+
action = "delete_pool",
104+
pool_id = %pool_id,
105+
"Admin deleting pool"
106+
);
85107
state.writer().delete_pool(&pool_id).await?;
86108
Ok(Json(AdminResponse {
87109
status: "deleted".to_string(),
@@ -92,6 +114,14 @@ pub async fn create_asset(
92114
State(state): State<Arc<AppState>>,
93115
Json(payload): Json<CreateAssetRequest>,
94116
) -> Result<Json<AdminResponse>, DeepBookError> {
117+
tracing::info!(
118+
action = "create_asset",
119+
asset_type = %payload.asset_type,
120+
symbol = %payload.symbol,
121+
name = %payload.name,
122+
decimals = %payload.decimals,
123+
"Admin creating asset"
124+
);
95125
state.writer().create_asset(payload).await?;
96126
Ok(Json(AdminResponse {
97127
status: "created".to_string(),
@@ -102,6 +132,11 @@ pub async fn delete_asset(
102132
State(state): State<Arc<AppState>>,
103133
Path(asset_type): Path<String>,
104134
) -> Result<Json<AdminResponse>, DeepBookError> {
135+
tracing::info!(
136+
action = "delete_asset",
137+
asset_type = %asset_type,
138+
"Admin deleting asset"
139+
);
105140
state.writer().delete_asset(&asset_type).await?;
106141
Ok(Json(AdminResponse {
107142
status: "deleted".to_string(),

crates/server/src/admin/routes.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ use super::handlers;
1414
use crate::server::AppState;
1515

1616
pub fn admin_routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
17-
Router::new()
18-
.route("/health", get(handlers::admin_health))
17+
// Authenticated routes
18+
let protected = Router::new()
1919
.route("/pools", post(handlers::create_pool))
2020
.route("/pools/{pool_id}", put(handlers::update_pool))
2121
.route("/pools/{pool_id}", delete(handlers::delete_pool))
2222
.route("/assets", post(handlers::create_asset))
2323
.route("/assets/{asset_type}", delete(handlers::delete_asset))
24-
.layer(from_fn_with_state(state, require_admin_auth))
24+
.layer(from_fn_with_state(state, require_admin_auth));
25+
26+
// Health check is unauthenticated for load balancer probes
27+
Router::new()
28+
.route("/health", get(handlers::admin_health))
29+
.merge(protected)
2530
}

crates/server/src/server.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ use deepbook_schema::*;
2121
use diesel::dsl::count_star;
2222
use diesel::dsl::{max, min};
2323
use diesel::{ExpressionMethods, QueryDsl};
24+
use governor::{Quota, RateLimiter};
25+
use secrecy::{ExposeSecret, Secret};
2426
use serde::Deserialize;
2527
use serde_json::Value;
2628
use std::net::{IpAddr, Ipv4Addr};
29+
use std::num::NonZeroU32;
2730
use std::time::{SystemTime, UNIX_EPOCH};
2831
use std::{collections::HashMap, net::SocketAddr};
2932
use sui_pg_db::DbArgs;
@@ -111,6 +114,12 @@ pub const DEPOSITED_ASSETS_PATH: &str = "/deposited_assets/:balance_manager_ids"
111114
pub const COLLATERAL_EVENTS_PATH: &str = "/collateral_events";
112115
pub const GET_POINTS_PATH: &str = "/get_points";
113116

117+
type AdminRateLimiter = RateLimiter<
118+
governor::state::NotKeyed,
119+
governor::state::InMemoryState,
120+
governor::clock::DefaultClock,
121+
>;
122+
114123
#[derive(Clone)]
115124
pub struct AppState {
116125
reader: Reader,
@@ -121,7 +130,8 @@ pub struct AppState {
121130
deepbook_package_id: String,
122131
deep_token_package_id: String,
123132
deep_treasury_id: String,
124-
admin_tokens: Vec<String>,
133+
admin_tokens: Vec<Secret<String>>,
134+
admin_auth_limiter: Arc<AdminRateLimiter>,
125135
margin_package_id: Option<String>,
126136
}
127137

@@ -147,15 +157,27 @@ impl AppState {
147157
.await?;
148158
let writer = Writer::new(database_url, args).await?;
149159

150-
let admin_tokens = admin_tokens
160+
let admin_tokens: Vec<Secret<String>> = admin_tokens
151161
.map(|s| {
152162
s.split(',')
153163
.map(|t| t.trim().to_string())
154164
.filter(|t| !t.is_empty())
165+
.map(Secret::new)
155166
.collect()
156167
})
157168
.unwrap_or_default();
158169

170+
if admin_tokens.is_empty() {
171+
tracing::warn!(
172+
"No admin tokens configured (ADMIN_TOKENS env var). Admin endpoints will reject all requests."
173+
);
174+
}
175+
176+
// Rate limiter: 10 attempts per minute for admin auth failures
177+
let admin_auth_limiter = Arc::new(RateLimiter::direct(Quota::per_minute(
178+
NonZeroU32::new(10).unwrap(),
179+
)));
180+
159181
Ok(Self {
160182
reader,
161183
writer,
@@ -166,6 +188,7 @@ impl AppState {
166188
deep_token_package_id,
167189
deep_treasury_id,
168190
admin_tokens,
191+
admin_auth_limiter,
169192
margin_package_id,
170193
})
171194
}
@@ -191,7 +214,14 @@ impl AppState {
191214
}
192215

193216
pub fn is_valid_admin_token(&self, token: &str) -> bool {
194-
self.admin_tokens.iter().any(|t| t == token)
217+
use subtle::ConstantTimeEq;
218+
self.admin_tokens
219+
.iter()
220+
.any(|t| t.expose_secret().as_bytes().ct_eq(token.as_bytes()).into())
221+
}
222+
223+
pub fn check_admin_rate_limit(&self) -> bool {
224+
self.admin_auth_limiter.check().is_ok()
195225
}
196226
}
197227

crates/server/src/writer.rs

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@
44
use crate::admin::handlers::{CreateAssetRequest, CreatePoolRequest, UpdatePoolRequest};
55
use crate::error::DeepBookError;
66
use deepbook_schema::schema;
7-
use diesel::{ExpressionMethods, QueryDsl};
7+
use diesel::{AsChangeset, ExpressionMethods, QueryDsl};
88
use diesel_async::RunQueryDsl;
99
use sui_pg_db::{Db, DbArgs};
1010
use url::Url;
1111

12+
#[derive(AsChangeset)]
13+
#[diesel(table_name = schema::pools)]
14+
struct PoolChangeset {
15+
pool_name: Option<String>,
16+
min_size: Option<i64>,
17+
lot_size: Option<i64>,
18+
tick_size: Option<i64>,
19+
}
20+
1221
#[derive(Clone)]
1322
pub struct Writer {
1423
db: Db,
@@ -56,31 +65,22 @@ impl Writer {
5665
.await
5766
.map_err(|e| DeepBookError::database(e.to_string()))?;
5867

59-
if let Some(name) = req.pool_name {
60-
diesel::update(schema::pools::table.filter(schema::pools::pool_id.eq(id)))
61-
.set(schema::pools::pool_name.eq(name))
62-
.execute(&mut conn)
63-
.await?;
64-
}
65-
if let Some(size) = req.min_size {
66-
diesel::update(schema::pools::table.filter(schema::pools::pool_id.eq(id)))
67-
.set(schema::pools::min_size.eq(size))
68-
.execute(&mut conn)
69-
.await?;
70-
}
71-
if let Some(size) = req.lot_size {
72-
diesel::update(schema::pools::table.filter(schema::pools::pool_id.eq(id)))
73-
.set(schema::pools::lot_size.eq(size))
74-
.execute(&mut conn)
75-
.await?;
76-
}
77-
if let Some(size) = req.tick_size {
68+
let changeset = PoolChangeset {
69+
pool_name: req.pool_name,
70+
min_size: req.min_size,
71+
lot_size: req.lot_size,
72+
tick_size: req.tick_size,
73+
};
74+
75+
let rows_affected =
7876
diesel::update(schema::pools::table.filter(schema::pools::pool_id.eq(id)))
79-
.set(schema::pools::tick_size.eq(size))
77+
.set(changeset)
8078
.execute(&mut conn)
8179
.await?;
82-
}
8380

81+
if rows_affected == 0 {
82+
return Err(DeepBookError::not_found(format!("pool {id}")));
83+
}
8484
Ok(())
8585
}
8686

@@ -91,10 +91,14 @@ impl Writer {
9191
.await
9292
.map_err(|e| DeepBookError::database(e.to_string()))?;
9393

94-
diesel::delete(schema::pools::table.filter(schema::pools::pool_id.eq(id)))
95-
.execute(&mut conn)
96-
.await?;
94+
let rows_affected =
95+
diesel::delete(schema::pools::table.filter(schema::pools::pool_id.eq(id)))
96+
.execute(&mut conn)
97+
.await?;
9798

99+
if rows_affected == 0 {
100+
return Err(DeepBookError::not_found(format!("pool {id}")));
101+
}
98102
Ok(())
99103
}
100104

@@ -128,10 +132,14 @@ impl Writer {
128132
.await
129133
.map_err(|e| DeepBookError::database(e.to_string()))?;
130134

131-
diesel::delete(schema::assets::table.filter(schema::assets::asset_type.eq(id)))
132-
.execute(&mut conn)
133-
.await?;
135+
let rows_affected =
136+
diesel::delete(schema::assets::table.filter(schema::assets::asset_type.eq(id)))
137+
.execute(&mut conn)
138+
.await?;
134139

140+
if rows_affected == 0 {
141+
return Err(DeepBookError::not_found(format!("asset {id}")));
142+
}
135143
Ok(())
136144
}
137145
}

0 commit comments

Comments
 (0)