security(external_user): protect unnecessary route access to external users

This commit is contained in:
Edouard Vanbelle
2026-06-02 14:46:27 +02:00
parent 21c06da700
commit 6d70a7000e
8 changed files with 236 additions and 19 deletions
+58
View File
@@ -21,11 +21,17 @@
//! [`super::admin`] — that variant exists because some handlers take
//! `headers: HeaderMap` directly instead of `AuthUser`.
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;
use uuid::Uuid;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
/// Require the caller to be an internal user. Returns `Ok(())` for
/// internal callers, `Err(403)` for externals.
@@ -90,3 +96,55 @@ pub async fn require_admin_user(
}
Ok(())
}
/// Axum middleware layer that blocks external users from a whole route
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
/// semantic meaning for externals — they own no calendars, no address
/// books, no home folder.
///
/// Must run AFTER the auth middleware so `CurrentUser` is in the
/// request extensions; in tower order that means the auth layer is
/// added LAST (outermost). If the layer fires on an unauthenticated
/// path (no `CurrentUser` populated), it simply passes through — the
/// inner handler is then responsible for the 401, and we don't blanket-
/// 403 traffic the auth layer would have rejected anyway.
///
/// Emits an `authz.external_user_blocked` audit event on rejection so
/// operators can spot which surfaces externals are probing.
pub async fn require_internal_user_layer(
State(state): State<Arc<AppState>>,
request: Request,
next: Next,
) -> Response {
let caller_id = request
.extensions()
.get::<Arc<CurrentUser>>()
.map(|cu| cu.id);
let (Some(caller_id), Some(svc)) = (
caller_id,
state
.auth_service
.as_ref()
.map(|s| &*s.auth_application_service),
) else {
// No auth populated, or auth disabled globally — pass through.
return next.run(request).await;
};
if let Err(err) = require_internal_user(svc, caller_id).await {
let path = request.uri().path().to_owned();
tracing::info!(
target: "audit",
event = "authz.external_user_blocked",
reason = "internal_only_surface",
caller_id = %caller_id,
path = %path,
"👮🏻‍♂️ External user blocked from internal-only route subtree"
);
return err.into_response();
}
next.run(request).await
}