diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 0aa1c863..cf81f53d 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1,7 +1,7 @@ use axum::{ Router, extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{ IntoResponse, sse::{Event, KeepAlive, Sse}, @@ -29,7 +29,7 @@ use crate::domain::repositories::drive_repository::DriveRepository; use crate::domain::services::authorization::{Resource, Subject}; use crate::interfaces::api::handlers::search_handler::clear_search_cache; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::admin::require_admin; +use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; use uuid::Uuid; @@ -128,14 +128,13 @@ pub fn admin_routes() -> Router> { ) } -/// Validate JWT and require admin role. Returns (user_id, role). -/// -/// Thin wrapper over the shared `require_admin` middleware helper so this -/// handler keeps a stable signature while the implementation lives next to -/// the new `subject_group_handler` that also needs it. -async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> { - require_admin(state, headers).await -} +// Every route under `/api/admin/*` is gated by the +// `require_admin` middleware layer wired at the router nest point +// (`routes.rs::admin_router`). Handlers no longer need an inline +// guard call — the caller is guaranteed to be admin by construction. +// Callers that need the caller's id read it from the `AuthUser` +// extractor (`middleware::auth::AuthUser`), populated by the outer +// `auth_middleware`. /// GET /api/admin/settings/oidc — get OIDC settings for the admin panel #[utoipa::path( @@ -151,9 +150,7 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, Str )] pub async fn get_oidc_settings( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let svc = state .admin_settings_service @@ -182,10 +179,10 @@ pub async fn get_oidc_settings( )] pub async fn save_oidc_settings( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Json(dto): Json, ) -> Result { - let (user_id, _) = admin_guard(&state, &headers).await?; + let user_id = auth_user.id; let svc = state .admin_settings_service @@ -207,10 +204,8 @@ pub async fn save_oidc_settings( /// POST /api/admin/settings/oidc/test — test OIDC discovery async fn test_oidc_connection( State(state): State>, - headers: HeaderMap, Json(dto): Json, ) -> Result { - admin_guard(&state, &headers).await?; let svc = state .admin_settings_service @@ -243,9 +238,7 @@ async fn test_oidc_connection( )] pub async fn get_storage_settings( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let svc = state .storage_settings_service @@ -274,10 +267,10 @@ pub async fn get_storage_settings( )] pub async fn save_storage_settings( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Json(dto): Json, ) -> Result { - let (user_id, _) = admin_guard(&state, &headers).await?; + let user_id = auth_user.id; let svc = state .storage_settings_service @@ -299,10 +292,8 @@ pub async fn save_storage_settings( /// POST /api/admin/settings/storage/test — test storage backend connection async fn test_storage_connection( State(state): State>, - headers: HeaderMap, Json(dto): Json, ) -> Result { - admin_guard(&state, &headers).await?; let svc = state .storage_settings_service @@ -335,9 +326,7 @@ async fn test_storage_connection( )] pub async fn get_migration_status( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let s = state.migration_state.read().await; Ok(Json(migration_state_to_dto(&s))) } @@ -357,12 +346,10 @@ pub async fn get_migration_status( )] pub async fn start_migration( State(state): State>, - headers: HeaderMap, Json(dto): Json, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; - admin_guard(&state, &headers).await?; // Check not already running. { @@ -435,10 +422,8 @@ pub async fn start_migration( )] pub async fn pause_migration( State(state): State>, - headers: HeaderMap, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; - admin_guard(&state, &headers).await?; let mut s = state.migration_state.write().await; if s.status != MigrationStatus::Running { @@ -466,10 +451,8 @@ pub async fn pause_migration( )] pub async fn resume_migration( State(state): State>, - headers: HeaderMap, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; - admin_guard(&state, &headers).await?; // Set status back to Running — the background task checks on each blob. let mut s = state.migration_state.write().await; @@ -498,10 +481,8 @@ pub async fn resume_migration( )] pub async fn complete_migration( State(state): State>, - headers: HeaderMap, ) -> Result { use crate::infrastructure::services::migration_blob_backend::MigrationStatus; - admin_guard(&state, &headers).await?; let s = state.migration_state.read().await; if s.status != MigrationStatus::Completed { @@ -538,10 +519,8 @@ pub async fn complete_migration( )] pub async fn verify_migration( State(state): State>, - headers: HeaderMap, Json(dto): Json, ) -> Result { - admin_guard(&state, &headers).await?; let pool = state .db_pool @@ -614,12 +593,7 @@ fn migration_state_to_dto( security(("bearerAuth" = [])), tag = "admin" )] -pub async fn generate_encryption_key( - State(state): State>, - headers: HeaderMap, -) -> Result { - admin_guard(&state, &headers).await?; - +pub async fn generate_encryption_key() -> Result { let key = crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key( ); @@ -677,9 +651,7 @@ fn build_backend_from_config( )] pub async fn get_dashboard_stats( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let auth = state .auth_service @@ -768,10 +740,8 @@ pub async fn get_dashboard_stats( )] pub async fn list_users( State(state): State>, - headers: HeaderMap, Query(query): Query, ) -> Result { - admin_guard(&state, &headers).await?; let auth = state .auth_service @@ -817,10 +787,8 @@ pub async fn list_users( )] pub async fn get_user( State(state): State>, - headers: HeaderMap, Path(id): Path, ) -> Result { - admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; @@ -854,10 +822,10 @@ pub async fn get_user( )] pub async fn delete_user( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; @@ -904,11 +872,11 @@ pub async fn delete_user( )] pub async fn update_user_role( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; @@ -955,11 +923,11 @@ pub async fn update_user_role( )] pub async fn update_user_active( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; @@ -1010,11 +978,9 @@ pub async fn update_user_active( )] pub async fn update_user_quota( State(state): State>, - headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { - admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; @@ -1056,10 +1022,8 @@ pub async fn update_user_quota( )] pub async fn create_user( State(state): State>, - headers: HeaderMap, Json(dto): Json, ) -> Result { - admin_guard(&state, &headers).await?; let auth = state .auth_service @@ -1097,11 +1061,9 @@ pub async fn create_user( )] pub async fn reset_user_password( State(state): State>, - headers: HeaderMap, Path(id): Path, Json(dto): Json, ) -> Result { - admin_guard(&state, &headers).await?; let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; @@ -1148,10 +1110,10 @@ pub async fn reset_user_password( )] pub async fn set_registration_setting( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Json(body): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let enabled = body .get("registration_enabled") @@ -1184,9 +1146,7 @@ pub async fn set_registration_setting( async fn reextract_audio_metadata( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let audio_service = state .applications @@ -1214,9 +1174,7 @@ async fn reextract_audio_metadata( /// Photos timeline by real capture date. Safe to re-run (idempotent upsert). async fn reextract_image_metadata( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let result = state .applications @@ -1262,9 +1220,7 @@ async fn reextract_image_metadata( )] async fn get_smtp_info( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let smtp = &state.core.config.smtp; let info = SmtpInfoDto { @@ -1294,10 +1250,8 @@ async fn get_smtp_info( /// returns 404 to keep the endpoint inert. async fn get_captured_email( State(state): State>, - headers: HeaderMap, Query(params): Query, ) -> Result { - admin_guard(&state, &headers).await?; if !std::env::var("OXICLOUD_SMTP_MOCK") .map(|v| v == "true" || v == "1") @@ -1354,10 +1308,10 @@ struct CapturedEmailQuery { )] async fn send_smtp_test( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let recipient = dto.to.trim().to_string(); if recipient.is_empty() { @@ -1469,9 +1423,7 @@ fn map_mgmt_err(err: &PluginMgmtError) -> AppError { /// GET /api/admin/plugins — list installed plugins. pub async fn list_plugins( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let plugins: Vec = mgmt.list().into_iter().map(PluginInfoDto::from).collect(); // `enabled` reports that the plugin *subsystem* is active (reaching here @@ -1486,11 +1438,11 @@ pub async fn list_plugins( /// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin. pub async fn set_plugin_enabled( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.set_enabled(&id, dto.enabled) .map_err(|e| map_mgmt_err(&e))?; @@ -1527,10 +1479,10 @@ pub async fn set_plugin_enabled( /// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`. pub async fn install_plugin( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, mut multipart: Multipart, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; let mut bundle: Option> = None; @@ -1591,10 +1543,10 @@ pub async fn install_plugin( /// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files. pub async fn delete_plugin( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?; @@ -1616,11 +1568,9 @@ pub async fn delete_plugin( /// structured log entries (newest first). pub async fn get_plugin_logs( State(state): State>, - headers: HeaderMap, Path(id): Path, Query(q): Query, ) -> Result { - admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let limit = q.limit.unwrap_or(50).clamp(1, 500); @@ -1644,10 +1594,10 @@ pub async fn get_plugin_logs( /// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs. pub async fn clear_plugin_logs( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?; @@ -1671,13 +1621,11 @@ pub async fn clear_plugin_logs( /// so `EventSource` works without setting headers. pub async fn stream_plugin_logs( State(state): State>, - headers: HeaderMap, Path(id): Path, ) -> Result { use tokio_stream::StreamExt; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; - admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; if !mgmt.list().iter().any(|p| p.id == id) { return Err(AppError::not_found("Plugin not found")); @@ -1705,10 +1653,8 @@ pub async fn stream_plugin_logs( /// GET /api/admin/plugins/{id}/retention — the plugin's effective retention. pub async fn get_plugin_retention( State(state): State>, - headers: HeaderMap, Path(id): Path, ) -> Result { - admin_guard(&state, &headers).await?; let mgmt = plugin_mgmt(&state)?; let settings = mgmt .get_retention(&id) @@ -1720,11 +1666,11 @@ pub async fn get_plugin_retention( /// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy. pub async fn set_plugin_retention( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, Path(id): Path, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let mgmt = plugin_mgmt(&state)?; mgmt.set_retention(&id, dto.into()) .await @@ -1768,9 +1714,7 @@ pub async fn set_plugin_retention( )] pub async fn list_all_drives( State(state): State>, - headers: HeaderMap, ) -> Result { - admin_guard(&state, &headers).await?; let drives = state .drive_repo .list_all() @@ -1806,10 +1750,8 @@ pub async fn list_all_drives( )] pub async fn list_drive_members_admin( State(state): State>, - headers: HeaderMap, axum::extract::Path(drive_id): axum::extract::Path, ) -> Result { - admin_guard(&state, &headers).await?; let grants = state .authorization .list_grants_on_resource(Resource::Drive(drive_id)) @@ -1869,11 +1811,11 @@ fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject { )] pub async fn add_drive_member_admin( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, axum::extract::Path(drive_id): axum::extract::Path, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let subject = admin_parse_subject(dto.subject.kind, dto.subject.id); let grant = state .drive_management_service @@ -1914,7 +1856,7 @@ pub async fn add_drive_member_admin( )] pub async fn update_drive_member_admin( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( Uuid, SubjectTypeDto, @@ -1922,7 +1864,7 @@ pub async fn update_drive_member_admin( )>, Json(dto): Json, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let subject = admin_parse_subject(kind, subject_id); let grant = state .drive_management_service @@ -1961,14 +1903,14 @@ pub async fn update_drive_member_admin( )] pub async fn remove_drive_member_admin( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<( Uuid, SubjectTypeDto, Uuid, )>, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; let subject = admin_parse_subject(kind, subject_id); state .drive_management_service @@ -2003,10 +1945,10 @@ pub async fn remove_drive_member_admin( )] pub async fn delete_drive_admin( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, axum::extract::Path(drive_id): axum::extract::Path, ) -> Result { - let (admin_id, _) = admin_guard(&state, &headers).await?; + let admin_id = auth_user.id; state .drive_management_service .delete_drive(admin_id, true, drive_id) @@ -2062,15 +2004,11 @@ fn internal_endpoints_disabled() -> axum::response::Response { )] pub async fn internal_trigger_sweep( State(state): State>, - headers: HeaderMap, ) -> axum::response::Response { use axum::response::IntoResponse; if !state.core.config.features.enable_admin_internal_endpoints { return internal_endpoints_disabled(); } - if let Err(e) = admin_guard(&state, &headers).await { - return e.into_response(); - } let svc = match state.storage_usage_service.as_ref() { Some(s) => s, None => { @@ -2142,16 +2080,12 @@ pub struct InternalTriggerGcQuery { )] pub async fn internal_trigger_gc( State(state): State>, - headers: HeaderMap, Query(query): Query, ) -> axum::response::Response { use axum::response::IntoResponse; if !state.core.config.features.enable_admin_internal_endpoints { return internal_endpoints_disabled(); } - if let Err(e) = admin_guard(&state, &headers).await { - return e.into_response(); - } let result = if query.force { state.core.dedup_service.garbage_collect_force().await } else { @@ -2218,16 +2152,12 @@ pub struct InternalTriggerGrantCleanupQuery { )] pub async fn internal_trigger_grant_cleanup( State(state): State>, - headers: HeaderMap, Query(query): Query, ) -> axum::response::Response { use axum::response::IntoResponse; if !state.core.config.features.enable_admin_internal_endpoints { return internal_endpoints_disabled(); } - if let Err(e) = admin_guard(&state, &headers).await { - return e.into_response(); - } // Daemon may be disabled by config even when the internal-endpoint // gate is on. Return 503 (rather than 404 or 500) so integration // tests can distinguish "surface not exposed" from "surface diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index aa1f69e6..0514f494 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -1,6 +1,6 @@ use axum::{ extract::{Json, Query, State}, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{IntoResponse, Response}, }; use serde_json::json; @@ -12,7 +12,6 @@ use crate::application::dtos::search_dto::{ use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::admin::require_admin; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; @@ -189,22 +188,25 @@ impl SearchHandler { } } - /// `DELETE /search/cache` — flush the shared moka search results - /// cache. Admin-only. + /// `DELETE /admin/search/cache` — flush the shared moka search + /// results cache. Admin-only. /// - /// AuthZ audit #14 (2026-07-12): pre-fix this endpoint required - /// only a valid JWT (via the top-level auth middleware) — any - /// authenticated user, including external / magic-link accounts, - /// could DELETE it in a loop and keep the results cache cold - /// indefinitely (sustained DoS on every subsequent `/api/search` - /// query). Now gated by `require_admin` (401 for missing token, - /// 403 for non-admin caller, 200 for admin). Audit line on success - /// so operator-driven flushes are traceable in security reviews. + /// AuthZ audit #14 (2026-07-12): pre-fix this endpoint lived at + /// `/api/search/cache` and required only a valid JWT — any + /// authenticated user (external / magic-link included) could + /// DELETE it in a loop and keep the results cache cold indefinitely + /// (sustained DoS on every subsequent `/api/search` query). Now + /// mounted at `/api/admin/search/cache`, gated by the + /// `require_admin` middleware layer on the `/api/admin` nest point. + /// The handler no longer needs an inline authz call — reaching + /// this code implies `AuthUser` is admin by construction. Audit + /// line on success so operator-driven flushes are traceable in + /// security reviews. pub(super) async fn clear_search_cache_impl( State(state): State>, - headers: HeaderMap, + auth_user: AuthUser, ) -> Result { - let (caller_id, _) = require_admin(&state, &headers).await?; + let caller_id = auth_user.id; info!("API: Clearing search cache"); let Some(search_service) = &state.applications.search_service else { @@ -396,7 +398,7 @@ pub async fn suggest_files( )] pub async fn clear_search_cache( state: State>, - headers: HeaderMap, + auth_user: AuthUser, ) -> Result { - SearchHandler::clear_search_cache_impl(state, headers).await + SearchHandler::clear_search_cache_impl(state, auth_user).await } diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index d82bb52b..60d23070 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -613,8 +613,19 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav) // in main.rs for protocol compliance, NOT under /api. - // Admin settings routes (protected by admin_guard inside the handler) - let admin_router = admin_handler::admin_routes().with_state(app_state.clone()); + // Admin settings routes — the whole subtree is admin-only by + // construction. The `require_admin` layer runs AFTER the outer + // `auth_middleware` (main.rs::protected_api), so it can rely on + // `CurrentUser` already being in the request extensions. Any new + // route added to `admin_handler::admin_routes()` inherits the + // gate automatically — implementors no longer have to remember + // to call `require_admin(&state, &headers).await?` inline, and a + // forgotten call can't silently expose a non-admin surface. + let admin_router = admin_handler::admin_routes() + .layer(axum::middleware::from_fn( + crate::interfaces::middleware::auth::require_admin, + )) + .with_state(app_state.clone()); router = router.nest("/admin", admin_router); // ReBAC subject-group management. All mutating routes are admin-gated; diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 2843fa1b..0ff998cb 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -389,6 +389,13 @@ fn dav_basic_auth_challenge(message: &'static str) -> Response { /// `CurrentUser` is the *live* role resolved by `auth_middleware` (see /// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured /// here within the flags-cache TTL. +/// +/// Denial shapes distinguish authn from authz: +/// - `CurrentUser` present, role != "admin" → 403 Forbidden. +/// - `CurrentUser` absent → 401 Unauthorized. Should not happen in +/// practice (auth_middleware guards against it), but the +/// defensive fallback returns the honest shape: "we don't know +/// who you are" is 401, not "we know you and refuse" (403). pub async fn require_admin(request: Request, next: Next) -> Response { // Get the CurrentUser inserted by auth_middleware if let Some(current_user) = request.extensions().get::>() { @@ -404,18 +411,16 @@ pub async fn require_admin(request: Request, next: Next) -> Response { role = %current_user.role, "👮🏻‍♂️ admin-only route denied for non-admin caller" ); - } else { - tracing::info!( - target: "audit", - event = "authz.admin_denied", - reason = "unauthenticated", - "👮🏻‍♂️ admin-only route reached with no authenticated user" - ); + return AuthError::AccessDenied("Admin role required".to_string()).into_response(); } - // Access denied - let error = AuthError::AccessDenied("Admin role required".to_string()); - error.into_response() + tracing::info!( + target: "audit", + event = "authz.admin_denied", + reason = "unauthenticated", + "👮🏻‍♂️ admin-only route reached with no authenticated user" + ); + AuthError::TokenNotProvided.into_response() } #[cfg(test)] diff --git a/tests/api/search_basic.hurl b/tests/api/search_basic.hurl index 43c94508..b034b24e 100644 --- a/tests/api/search_basic.hurl +++ b/tests/api/search_basic.hurl @@ -25,6 +25,22 @@ # ============================================================= +# ───────────────────────────────────────────────────────────── +# Pre-setup — anonymous request pin. +# +# `DELETE /api/admin/search/cache` with NO credentials must land as +# 401 Unauthorized (from `auth_middleware`, before the admin gate +# even runs). Kept at the very top of the file so no earlier +# request has populated any auth state that could accidentally +# authenticate this request. `[Options] cookie-storage-clear` was +# tried earlier but isn't supported in Hurl 8.0.1, so we rely on +# ordering instead — this DELETE runs FIRST, before any login. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/search/cache + +HTTP 401 + + # ───────────────────────────────────────────────────────────── # Setup — admin login + bob (re-)provisioning # ───────────────────────────────────────────────────────────── @@ -273,11 +289,11 @@ Authorization: Bearer {{bob_token}} HTTP 403 -DELETE {{base_url}}/api/admin/search/cache - -HTTP 401 - - +# The unauthenticated 401 case is pinned at the top of the file +# (before any login has run) — see the pre-setup block. Placing it +# there instead of here avoids relying on Hurl's cookie / auth +# behaviour, which `cookie-storage-clear` (unsupported in 8.0.1) +# would otherwise be needed to reset. DELETE {{base_url}}/api/admin/search/cache Authorization: Bearer {{admin_token}}