Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 8 additions & 3 deletions src/console/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,16 @@ mod tests {
let state = AppState::new("test-secret".to_string());
let session_id = state.create_session("k8s-token".to_string())?;
let cookie = format!("session={session_id}");
let protected = Router::new()
.route("/api/v1/protected", get(|| async { "ok" }))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.route("/api/v1/logout", post(logout))
.route("/api/v1/protected", get(|| async { "ok" }))
.with_state(state.clone())
.layer(middleware::from_fn_with_state(state, auth_middleware));
.merge(protected)
.with_state(state);

let logout_response = app
.clone()
Expand Down
47 changes: 20 additions & 27 deletions src/console/middleware/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,44 +29,32 @@ pub async fn auth_middleware(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, Response> {
) -> Response {
// Allow CORS preflight without 401 (browser would treat as CORS failure)
if request.method() == Method::OPTIONS {
return Ok(next.run(request).await);
return next.run(request).await;
}
// Unauthenticated paths
let path = request.uri().path();
if path == "/healthz"
|| path == "/readyz"
|| path == "/metrics"
|| path.starts_with("/api/v1/login")
|| path.starts_with("/api/v1/logout")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/v1")
{
return Ok(next.run(request).await);
}

// Parse session cookie
let cookies = request
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");

let token = session_cookie_value(cookies)
.ok_or_else(|| unauthorized_response("Missing or invalid session"))?;
let Some(token) = session_cookie_value(cookies) else {
return unauthorized_response("Missing or invalid session");
};

let claims = state
.resolve_session(token)
.map_err(|source| Error::Session { source }.into_response())?
.ok_or_else(|| unauthorized_response("Missing or invalid session"))?;
let claims = match state.resolve_session(token) {
Ok(Some(claims)) => claims,
Ok(None) => return unauthorized_response("Missing or invalid session"),
Err(source) => return Error::Session { source }.into_response(),
};

// Stash claims for handlers
request.extensions_mut().insert(claims);

Ok(next.run(request).await)
next.run(request).await
}

fn unauthorized_response(message: &str) -> Response {
Expand Down Expand Up @@ -124,18 +112,23 @@ mod tests {
}

#[tokio::test]
async fn static_paths_do_not_require_session() -> Result<(), Box<dyn std::error::Error>> {
async fn options_requests_bypass_authentication() -> Result<(), Box<dyn std::error::Error>> {
let state = AppState::new("test-secret".to_string());
let app = Router::new()
.route("/", get(|| async { "ui" }))
.route("/protected", get(|| async { "ok" }))
.with_state(state.clone())
.layer(middleware::from_fn_with_state(state, auth_middleware));

let response = app
.oneshot(Request::builder().uri("/").body(Body::empty())?)
.oneshot(
Request::builder()
.method(Method::OPTIONS)
.uri("/protected")
.body(Body::empty())?,
)
.await?;

assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
Ok(())
}
}
17 changes: 13 additions & 4 deletions src/console/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,22 @@ use crate::{

/// Login / session routes (partially unauthenticated)
pub fn auth_routes() -> Router<AppState> {
auth_routes_with_config(AdmissionConfig::for_endpoint(
public_auth_routes_with_config(AdmissionConfig::for_endpoint(
AdmissionEndpoint::ConsoleLogin,
))
.merge(session_routes())
}

pub(crate) fn auth_routes_with_config(config: AdmissionConfig) -> Router<AppState> {
auth_routes_with_admission(AdmissionControl::new(config))
pub(crate) fn public_auth_routes_with_config(config: AdmissionConfig) -> Router<AppState> {
public_auth_routes_with_admission(AdmissionControl::new(config))
}

#[cfg(test)]
fn auth_routes_with_admission(admission: AdmissionControl) -> Router<AppState> {
public_auth_routes_with_admission(admission).merge(session_routes())
}

fn public_auth_routes_with_admission(admission: AdmissionControl) -> Router<AppState> {
let login = Router::new().route(
"/login",
post(handlers::auth::login).route_layer(middleware::from_fn_with_state(
Expand All @@ -49,7 +55,10 @@ fn auth_routes_with_admission(admission: AdmissionControl) -> Router<AppState> {
Router::new()
.merge(login)
.route("/logout", post(handlers::auth::logout))
.route("/session", get(handlers::auth::session_check))
}

pub(crate) fn session_routes() -> Router<AppState> {
Router::new().route("/session", get(handlers::auth::session_check))
}

async fn enforce_login_admission(
Expand Down
47 changes: 38 additions & 9 deletions src/console/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,11 @@ pub async fn run(port: u16) -> Result<(), Box<dyn std::error::Error>> {
// OpenAPI / Swagger (unauthenticated)
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
// REST API v1
.nest("/api/v1", api_routes(login_admission_config))
.nest("/api/v1", api_routes(login_admission_config, state.clone()))
// Shared state
.with_state(state.clone());
let app = with_static_frontend(app)
// Middleware runs in reverse order: Trace -> Compression -> Cors -> auth
.layer(middleware::from_fn_with_state(
state.clone(),
crate::console::middleware::auth::auth_middleware,
))
// Middleware runs in reverse order: Trace -> Compression -> Cors
.layer(
CorsLayer::new()
.allow_origin(cors_origins)
Expand Down Expand Up @@ -144,15 +140,25 @@ pub async fn run(port: u16) -> Result<(), Box<dyn std::error::Error>> {
}

/// Merge all `/api/v1` route trees.
fn api_routes(login_admission_config: AdmissionConfig) -> Router<AppState> {
Router::new()
.merge(routes::auth_routes_with_config(login_admission_config))
fn api_routes(login_admission_config: AdmissionConfig, state: AppState) -> Router<AppState> {
let protected = Router::new()
.merge(routes::session_routes())
.merge(routes::tenant_routes())
.merge(routes::pool_routes())
.merge(routes::pod_routes())
.merge(routes::event_routes())
.merge(routes::cluster_routes())
.merge(routes::topology_routes())
.route_layer(middleware::from_fn_with_state(
state,
crate::console::middleware::auth::auth_middleware,
));

Router::new()
.merge(routes::public_auth_routes_with_config(
login_admission_config,
))
.merge(protected)
}

fn with_static_frontend(app: Router) -> Router {
Expand Down Expand Up @@ -321,6 +327,29 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn api_router_only_exposes_explicit_public_auth_routes()
-> Result<(), Box<dyn std::error::Error>> {
let state = AppState::new("test-secret".to_string());
let app = api_routes(
AdmissionConfig::for_endpoint(AdmissionEndpoint::ConsoleLogin),
state.clone(),
)
.with_state(state);

let protected = app
.clone()
.oneshot(Request::get("/session").body(Body::empty())?)
.await?;
assert_eq!(protected.status(), StatusCode::UNAUTHORIZED);

let public = app
.oneshot(Request::post("/logout").body(Body::empty())?)
.await?;
assert_eq!(public.status(), StatusCode::OK);
Ok(())
}

fn temp_static_dir() -> std::io::Result<PathBuf> {
let id = NEXT_TEMP_DIR_ID.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
Expand Down
40 changes: 20 additions & 20 deletions src/reconcile/pool_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ async fn reconcile_single_pool_lifecycle(
.await
{
Ok(status) => status,
Err(decision) => return decision,
Err(decision) => return *decision,
};

return cleanup_decommissioned_pool(ctx, tenant, namespace, pool, status).await;
Expand Down Expand Up @@ -570,69 +570,69 @@ async fn verify_decommissioned_pool_for_cleanup(
pool: &Pool,
existing: &PoolDecommissionStatus,
cluster_domain: &str,
) -> Result<PoolDecommissionStatus, PoolLifecycleDecision> {
) -> Result<PoolDecommissionStatus, Box<PoolLifecycleDecision>> {
let matched_pool = match find_rustfs_pool(client, tenant, namespace, pool, cluster_domain).await
{
Ok(matched_pool) => matched_pool,
Err(error) if error.is_retriable() => {
return Err(cleanup_retriable_decision(
return Err(Box::new(cleanup_retriable_decision(
existing.clone(),
error.reason(),
error.message(),
));
)));
}
Err(error) => {
return Err(failed_decision(
return Err(Box::new(failed_decision(
existing.request_id.clone(),
error.reason(),
error.message(),
));
)));
}
};
let pool_id = matched_pool.item.id.to_string();

let Some(existing_pool_id) = existing.rustfs_pool_id.as_deref() else {
return Err(failed_decision(
return Err(Box::new(failed_decision(
existing.request_id.clone(),
"RustfsPoolIdentityMissing",
"recorded decommission status is missing rustfsPoolID; refusing cleanup",
));
)));
};
if existing_pool_id != pool_id {
let message = format!(
"recorded RustFS pool id '{}' no longer matches observed pool id '{}'",
existing_pool_id, pool_id
);
return Err(failed_decision(
return Err(Box::new(failed_decision(
existing.request_id.clone(),
"RustfsPoolIdentityMismatch",
&message,
));
)));
}

let Some(existing_hash) = existing.endpoint_set_hash.as_deref() else {
return Err(failed_decision(
return Err(Box::new(failed_decision(
existing.request_id.clone(),
"RustfsPoolIdentityMissing",
"recorded decommission status is missing endpointSetHash; refusing cleanup",
));
)));
};
if existing_hash != matched_pool.expected_endpoint_set_hash {
return Err(failed_decision(
return Err(Box::new(failed_decision(
existing.request_id.clone(),
"RustfsPoolIdentityMismatch",
"recorded endpoint set hash no longer matches the expected pool cmdline",
));
)));
}

let rustfs_status = match client.pool_status_by_id(&pool_id).await {
Ok(status) => status,
Err(error) => {
return Err(cleanup_retriable_decision(
return Err(Box::new(cleanup_retriable_decision(
existing.clone(),
"RustfsDecommissionStatusFailed",
&error.to_string(),
));
)));
}
};

Expand All @@ -656,19 +656,19 @@ async fn verify_decommissioned_pool_for_cleanup(
&matched_pool.expected_endpoint_set_hash,
)
.map_err(|message| {
failed_decision(
Box::new(failed_decision(
existing.request_id.clone(),
"RustfsPoolIdentityMismatch",
&message,
)
))
})?;

if !matches!(status.phase, Some(PoolDecommissionPhase::Complete)) {
return Err(failed_decision(
return Err(Box::new(failed_decision(
existing.request_id.clone(),
"RustfsDecommissionNotComplete",
"RustFS no longer reports the pool decommission as complete; refusing cleanup",
));
)));
}

Ok(status)
Expand Down
Loading