Skip to content

Commit 1e688ad

Browse files
authored
Merge pull request #100 from BaseIntelligence/fix/gateway-admin-auth
fix(gateway): require bearer auth for /v1/admin/*
2 parents 528f6c7 + e8e1bae commit 1e688ad

18 files changed

Lines changed: 468 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Working branch: **`main`**. Prod ships from annotated tags `v*.*.*` cut on `main
2828
| Key | Who | Needed for |
2929
|-----|-----|------------|
3030
| `gateway_sk` | Gateway | Bundle **seal** signatures (`POST /v1/admin/seal`) |
31+
| `gateway_admin_token` | Gateway + seal scripts | Bearer for **`/v1/admin/*`** (seal, backends, attest-grant). **Required** when `BASE_GATEWAY_REQUIRE_OWNER=1` |
3132
| `prism_sk` / `design_sk` | Challenge / smoke | Signed leaves (`POST /v1/weights/raw`); pubs must match trust root |
3233
| Gateway owner wallet + `BASE_GATEWAY_REQUIRE_OWNER` | Gateway | Master-only **identity** check (live/prod). **Not** required to seal or serve `/v1/weights/latest` |
3334
| Validator wallet | Validator | On-chain weight **submit** only — validators *fetch* sealed weights; they do not need a gateway wallet |

Cargo.lock

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

bins/weights-smoke/src/main.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ struct Args {
8282
/// after an anti-cheat false positive is overridden by the operator).
8383
#[arg(long = "score", value_name = "HOTKEY_HEX:SCORE")]
8484
score_overrides: Vec<String>,
85+
86+
/// Admin bearer for `POST /v1/admin/seal` (prod requires this).
87+
#[arg(long, env = "BASE_GATEWAY_ADMIN_TOKEN")]
88+
admin_token: Option<String>,
89+
90+
/// File containing the admin bearer token (single line).
91+
#[arg(long, env = "BASE_GATEWAY_ADMIN_TOKEN_FILE")]
92+
admin_token_file: Option<PathBuf>,
8593
}
8694

8795
#[tokio::main]
@@ -166,24 +174,49 @@ fn score_map(
166174
scores
167175
}
168176

177+
fn load_admin_token(args: &Args) -> Result<Option<String>, String> {
178+
if let Some(t) = args.admin_token.as_ref() {
179+
let t = t.trim();
180+
if t.is_empty() {
181+
return Err("admin token is empty".into());
182+
}
183+
return Ok(Some(t.to_owned()));
184+
}
185+
if let Some(path) = args.admin_token_file.as_ref() {
186+
let raw = std::fs::read_to_string(path)
187+
.map_err(|e| format!("read admin token file {}: {e}", path.display()))?;
188+
let t = raw.trim();
189+
if t.is_empty() {
190+
return Err(format!("admin token file {} is empty", path.display()));
191+
}
192+
return Ok(Some(t.to_owned()));
193+
}
194+
Ok(None)
195+
}
196+
169197
async fn admin_seal_and_check_latest(
170198
gateway: &str,
171199
epoch: u64,
172200
netuid: u16,
173201
tip: u64,
202+
admin_token: Option<&str>,
174203
) -> Result<(), String> {
175204
let http = reqwest::Client::builder()
176205
.timeout(Duration::from_mins(1))
177206
.build()
178207
.map_err(|e| e.to_string())?;
179208
let base = gateway.trim_end_matches('/');
180-
let seal = http
209+
let mut req = http
181210
.post(format!("{base}/v1/admin/seal"))
182211
.json(&serde_json::json!({
183212
"epoch": epoch,
184213
"netuid": netuid,
185214
"block_b": tip,
186-
}))
215+
}));
216+
if let Some(token) = admin_token {
217+
req = req.header("Authorization", format!("Bearer {token}"));
218+
}
219+
let seal = req
187220
.send()
188221
.await
189222
.map_err(|e| format!("admin/seal transport: {e}"))?;
@@ -305,5 +338,13 @@ async fn run() -> Result<(), String> {
305338
outcomes.len()
306339
);
307340

308-
admin_seal_and_check_latest(&args.gateway, epoch, args.netuid, tip).await
341+
let admin_token = load_admin_token(&args)?;
342+
admin_seal_and_check_latest(
343+
&args.gateway,
344+
epoch,
345+
args.netuid,
346+
tip,
347+
admin_token.as_deref(),
348+
)
349+
.await
309350
}

crates/gateway-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ parking_lot = "0.12"
1717
serde = { version = "1", features = ["derive"] }
1818
serde_json = "1"
1919
thiserror = "2"
20+
tracing = "0.1"
2021
uuid = { version = "1", features = ["v4", "serde"] }
2122

2223
[lints]

crates/gateway-core/src/admin_attest.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
//! `verified` attestation row with `quote = NULL` and an explicit
88
//! `reason: admin-exempt: …`, under the gateway's own control-plane hotkey.
99
//!
10-
//! Access control matches `/v1/admin/seal`: the route is only mounted on the
11-
//! master-plane gateway, which deploys bind it to the internal network / VPC
12-
//! only — possession of shell access on the master is the credential. The
13-
//! written row is auditable in `attestation.reason`.
10+
//! Access control matches `/v1/admin/seal`: Bearer token via
11+
//! `BASE_GATEWAY_ADMIN_TOKEN` / `_FILE` (required when
12+
//! `BASE_GATEWAY_REQUIRE_OWNER=1`). Do not treat network placement alone as
13+
//! auth — production historically published these routes on `:80`/`:443`.
14+
//! The written row is auditable in `attestation.reason`.
1415
1516
use axum::extract::State;
1617
use axum::http::StatusCode;
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
//! Bearer auth for master-only `/v1/admin/*` control-plane routes.
2+
//!
3+
//! Prod historically published these on `:80`/`:443` with no app auth. Policy:
4+
//! - token env/file set → require matching `Authorization: Bearer`
5+
//! - `BASE_GATEWAY_REQUIRE_OWNER=1` without token → fail closed at load
6+
//! - otherwise (local/test) → open + warn
7+
8+
use std::sync::Arc;
9+
10+
use axum::extract::{Request, State};
11+
use axum::http::{header, StatusCode};
12+
use axum::middleware::Next;
13+
use axum::response::{IntoResponse, Response};
14+
use axum::Json;
15+
use thiserror::Error;
16+
17+
/// Env: raw admin bearer token (prefer the file form in deploy).
18+
pub const ADMIN_TOKEN_ENV: &str = "BASE_GATEWAY_ADMIN_TOKEN";
19+
/// Env: path to a single-line admin bearer token file (mode 0400).
20+
pub const ADMIN_TOKEN_FILE_ENV: &str = "BASE_GATEWAY_ADMIN_TOKEN_FILE";
21+
/// Same flag the gateway uses for master-only owner check.
22+
pub const REQUIRE_OWNER_ENV: &str = "BASE_GATEWAY_REQUIRE_OWNER";
23+
24+
/// Admin auth configuration errors.
25+
#[derive(Debug, Error)]
26+
pub enum AdminAuthError {
27+
/// Missing/empty/unreadable token configuration.
28+
#[error("{0}")]
29+
Config(String),
30+
}
31+
32+
/// Shared admin-auth state for the axum middleware.
33+
#[derive(Clone, Debug)]
34+
pub struct AdminAuth {
35+
/// When `Some`, Bearer must match. When `None`, admin routes are open.
36+
token: Option<Arc<[u8]>>,
37+
}
38+
39+
impl AdminAuth {
40+
/// Load from env. Fails closed when owner-check is required but no token.
41+
///
42+
/// # Errors
43+
///
44+
/// Missing token under `BASE_GATEWAY_REQUIRE_OWNER=1`, or unreadable file.
45+
pub fn from_env() -> Result<Self, AdminAuthError> {
46+
let token = load_token()?;
47+
let require_owner = std::env::var(REQUIRE_OWNER_ENV)
48+
.is_ok_and(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES"));
49+
if token.is_none() {
50+
if require_owner {
51+
return Err(AdminAuthError::Config(format!(
52+
"{REQUIRE_OWNER_ENV}=1 requires {ADMIN_TOKEN_ENV} or {ADMIN_TOKEN_FILE_ENV} \
53+
(refusing to expose open /v1/admin/* on a public listener)"
54+
)));
55+
}
56+
tracing::warn!(
57+
event = "gateway_admin_auth_open",
58+
"BASE_GATEWAY_ADMIN_TOKEN unset; /v1/admin/* is unauthenticated \
59+
(OK for local tests only)"
60+
);
61+
} else {
62+
tracing::info!(
63+
event = "gateway_admin_auth_enabled",
64+
"Bearer auth required for /v1/admin/*"
65+
);
66+
}
67+
Ok(Self {
68+
token: token.map(Into::into),
69+
})
70+
}
71+
72+
/// Test helper: require this exact bearer token.
73+
#[must_use]
74+
pub fn require_token(token: impl Into<String>) -> Self {
75+
Self {
76+
token: Some(Arc::from(token.into().into_bytes().into_boxed_slice())),
77+
}
78+
}
79+
80+
/// Test helper: leave admin routes open.
81+
#[must_use]
82+
pub fn open() -> Self {
83+
Self { token: None }
84+
}
85+
}
86+
87+
fn load_token() -> Result<Option<Vec<u8>>, AdminAuthError> {
88+
if let Ok(raw) = std::env::var(ADMIN_TOKEN_ENV) {
89+
let t = raw.trim();
90+
if t.is_empty() {
91+
return Err(AdminAuthError::Config(format!(
92+
"{ADMIN_TOKEN_ENV} is set but empty"
93+
)));
94+
}
95+
return Ok(Some(t.as_bytes().to_vec()));
96+
}
97+
if let Ok(path) = std::env::var(ADMIN_TOKEN_FILE_ENV) {
98+
let raw = std::fs::read_to_string(&path).map_err(|e| {
99+
AdminAuthError::Config(format!("read {ADMIN_TOKEN_FILE_ENV} ({path}): {e}"))
100+
})?;
101+
let t = raw.trim();
102+
if t.is_empty() {
103+
return Err(AdminAuthError::Config(format!(
104+
"{ADMIN_TOKEN_FILE_ENV} ({path}) is empty"
105+
)));
106+
}
107+
return Ok(Some(t.as_bytes().to_vec()));
108+
}
109+
Ok(None)
110+
}
111+
112+
/// Axum middleware: gate `/v1/admin` and `/v1/admin/*`.
113+
pub async fn admin_auth_middleware(
114+
State(auth): State<AdminAuth>,
115+
request: Request,
116+
next: Next,
117+
) -> Response {
118+
let path = request.uri().path();
119+
if !is_admin_path(path) {
120+
return next.run(request).await;
121+
}
122+
let Some(expected) = auth.token.as_ref() else {
123+
return next.run(request).await;
124+
};
125+
let provided = bearer_from_headers(request.headers());
126+
match provided {
127+
Some(got) if ct_eq(got.as_bytes(), expected.as_ref()) => next.run(request).await,
128+
_ => (
129+
StatusCode::UNAUTHORIZED,
130+
Json(serde_json::json!({
131+
"error": "admin bearer token required",
132+
"code": "admin_unauthorized",
133+
})),
134+
)
135+
.into_response(),
136+
}
137+
}
138+
139+
fn is_admin_path(path: &str) -> bool {
140+
path == "/v1/admin" || path.starts_with("/v1/admin/")
141+
}
142+
143+
fn bearer_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
144+
let raw = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
145+
let token = raw
146+
.strip_prefix("Bearer ")
147+
.or_else(|| raw.strip_prefix("bearer "))?;
148+
let token = token.trim();
149+
if token.is_empty() {
150+
None
151+
} else {
152+
Some(token.to_owned())
153+
}
154+
}
155+
156+
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
157+
if a.len() != b.len() {
158+
return false;
159+
}
160+
let mut diff = 0u8;
161+
for (x, y) in a.iter().zip(b.iter()) {
162+
diff |= x ^ y;
163+
}
164+
diff == 0
165+
}
166+
167+
#[cfg(test)]
168+
mod tests {
169+
use super::{ct_eq, is_admin_path};
170+
171+
#[test]
172+
fn admin_path_prefix() {
173+
assert!(is_admin_path("/v1/admin/seal"));
174+
assert!(is_admin_path("/v1/admin/backends"));
175+
assert!(is_admin_path("/v1/admin"));
176+
assert!(!is_admin_path("/v1/weights/latest"));
177+
assert!(!is_admin_path("/v1/administration"));
178+
}
179+
180+
#[test]
181+
fn ct_eq_basic() {
182+
assert!(ct_eq(b"abc", b"abc"));
183+
assert!(!ct_eq(b"abc", b"abd"));
184+
assert!(!ct_eq(b"abc", b"ab"));
185+
}
186+
}

crates/gateway-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
//! the workspace LOC cap. `gateway` re-exports everything here; external
33
//! callers should keep importing `gateway::*`.
44
//!
5+
//! - [`admin_auth`]: Bearer gate for `/v1/admin/*`.
56
//! - [`admin_attest`]: master-only owner credit for non-TEE runtimes.
67
//! - [`weights_store`]: raw-weight leaf row + in-memory store + ingress errors.
78
89
#![forbid(unsafe_code)]
910

1011
pub mod admin_attest;
12+
pub mod admin_auth;
1113
pub mod weights_store;

crates/gateway/src/lib.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ use tokio::net::TcpListener;
3333

3434
mod gw_config;
3535

36-
pub use api::{GatewayState, SharedChain};
36+
pub use api::{registry_router, GatewayState, SharedChain};
3737
pub use gateway_core::admin_attest::{
3838
admin_attest_grant_router, AttestGrantRequest, AttestGrantResponse, AttestGrantState,
3939
ATTEST_GRANT_ROUTE,
4040
};
41+
pub use gateway_core::admin_auth::{
42+
admin_auth_middleware, AdminAuth, AdminAuthError, ADMIN_TOKEN_ENV, ADMIN_TOKEN_FILE_ENV,
43+
};
4144
pub use gateway_registry::{
4245
Backend, BackendView, CreateBackend, Registry, RegistryConfig, RegistryError, DEFAULT_COOLDOWN,
4346
DEFAULT_FAILURE_THRESHOLD,
@@ -283,10 +286,11 @@ pub fn build_app(
283286
)
284287
.map_err(GatewayError::HttpClient)?;
285288
let app = app::build_router(metrics, state, tls)?;
286-
Ok(match extra {
289+
let app = match extra {
287290
Some(extra) => app.merge(extra),
288291
None => app,
289-
})
292+
};
293+
apply_admin_auth(app)
290294
}
291295

292296
/// Like [`build_app`] with injected trust root and weight store (tests / hydrate).
@@ -329,7 +333,21 @@ pub fn build_app_with_bundles(
329333
) -> Result<Router, GatewayError> {
330334
let state = GatewayState::with_parts(registry, chain, challenges, weights, bundles)
331335
.map_err(GatewayError::HttpClient)?;
332-
app::build_router(metrics, state, tls)
336+
let app = app::build_router(metrics, state, tls)?;
337+
apply_admin_auth(app)
338+
}
339+
340+
/// Attach `/v1/admin/*` bearer middleware.
341+
///
342+
/// # Errors
343+
///
344+
/// [`AdminAuth::from_env`] fail-closed config errors.
345+
pub fn apply_admin_auth(app: Router) -> Result<Router, GatewayError> {
346+
let auth = AdminAuth::from_env().map_err(|e| GatewayError::Config(e.to_string()))?;
347+
Ok(app.layer(axum::middleware::from_fn_with_state(
348+
auth,
349+
admin_auth_middleware,
350+
)))
333351
}
334352

335353
/// Master check → telemetry → bind → axum serve until `shutdown` completes.

0 commit comments

Comments
 (0)