security(/api/dedup): normalize dedup admin routes into /api/admin
/dedup/stats -> /api/admin/dedup/stats
/dedup/recalculate -> /api/admin/dedup/recalculate
This commit is contained in:
@@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::services::authorization::{Resource, Subject};
|
||||
use crate::interfaces::api::handlers::dedup_handler::{get_stats, recalculate_stats};
|
||||
use crate::interfaces::api::handlers::search_handler::clear_search_cache;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
@@ -96,6 +97,16 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// `/api/search/cache` pre-2026-07-17; the URL now declares
|
||||
// its admin intent up front.
|
||||
.route("/search/cache", delete(clear_search_cache))
|
||||
// Dedup — global storage stats + integrity recalculation
|
||||
// (AuthZ audit #24 + #25, 2026-07-17). Both are operator-only
|
||||
// observability / maintenance surfaces (blob-count-level data
|
||||
// + verify_integrity sweep). Moved here from `/api/dedup/*`
|
||||
// so the URL declares admin intent and the middleware layer
|
||||
// enforces it — same pattern as `search/cache` above. The
|
||||
// any-authenticated sibling routes (`/check`, `/check-batch`,
|
||||
// `/blob/{hash}`) stay at `/api/dedup/*`.
|
||||
.route("/dedup/stats", get(get_stats))
|
||||
.route("/dedup/recalculate", post(recalculate_stats))
|
||||
// SMTP diagnostics
|
||||
.route("/smtp/info", get(get_smtp_info))
|
||||
.route("/smtp/test", post(send_smtp_test))
|
||||
|
||||
@@ -218,18 +218,16 @@ impl DedupHandler {
|
||||
/// - Deduplication ratio
|
||||
pub(super) async fn get_stats_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
_auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
// Admin-only — global dedup statistics are sensitive infrastructure data
|
||||
if auth_user.role != "admin" {
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Admin role required"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// AuthZ audit #24 (2026-07-17): admin check moved to the
|
||||
// `/api/admin/*` middleware layer. Reaching this handler means
|
||||
// the caller is admin by construction — the bespoke role
|
||||
// string comparison here (`auth_user.role != "admin"` → 403
|
||||
// with a hand-rolled JSON body, no audit line) is gone. The
|
||||
// route is registered at `admin_handler::admin_routes()`;
|
||||
// moving the URL to `/api/admin/dedup/stats` also declares
|
||||
// the admin intent up front.
|
||||
let dedup = &state.core.dedup_service;
|
||||
let stats = dedup.get_stats().await;
|
||||
|
||||
@@ -343,16 +341,10 @@ impl DedupHandler {
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
// Admin-only — integrity verification is a privileged operation
|
||||
if auth_user.role != "admin" {
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Admin role required"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// AuthZ audit #25 (2026-07-17): admin check moved to the
|
||||
// `/api/admin/*` middleware layer — see the sibling
|
||||
// `get_stats_impl` comment. `auth_user` is kept so the
|
||||
// success-side audit line carries the caller id.
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Verify integrity first
|
||||
@@ -392,6 +384,21 @@ impl DedupHandler {
|
||||
savings_percentage: savings_pct,
|
||||
};
|
||||
|
||||
// AuthZ audit #25 (2026-07-17): integrity recalculation is a
|
||||
// low-frequency privileged operation — landing an audit event
|
||||
// so security reviews can see who ran verify + integrity
|
||||
// sweeps and when. The pre-fix path emitted no audit line at
|
||||
// all (the accepted 200 was silent from the security POV).
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "dedup.integrity_recalculated",
|
||||
caller_id = %auth_user.id,
|
||||
unique_blobs = response.unique_blobs,
|
||||
total_references = response.total_references,
|
||||
bytes_saved = response.bytes_saved,
|
||||
"🧮 dedup integrity verified and stats recomputed by admin",
|
||||
);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
@@ -453,12 +460,13 @@ pub async fn check_hashes_batch(
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/dedup/stats",
|
||||
path = "/api/admin/dedup/stats",
|
||||
responses(
|
||||
(status = 200, description = "Deduplication statistics", body = StatsResponse),
|
||||
(status = 403, description = "Admin role required"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 403, description = "Caller is not an admin"),
|
||||
),
|
||||
tag = "dedup",
|
||||
tag = "admin",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn get_stats(state: State<GlobalState>, auth_user: AuthUser) -> impl IntoResponse {
|
||||
@@ -489,13 +497,14 @@ pub async fn get_blob(
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/dedup/recalculate",
|
||||
path = "/api/admin/dedup/recalculate",
|
||||
responses(
|
||||
(status = 200, description = "Statistics after integrity verification", body = StatsResponse),
|
||||
(status = 403, description = "Admin role required"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 403, description = "Caller is not an admin"),
|
||||
(status = 500, description = "Integrity verification failed"),
|
||||
),
|
||||
tag = "dedup",
|
||||
tag = "admin",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn recalculate_stats(
|
||||
|
||||
@@ -391,18 +391,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Create routes for deduplication endpoints.
|
||||
// All handlers are free functions — see dedup_handler.rs for why
|
||||
// #[utoipa::path] cannot be applied to DedupHandler impl methods directly.
|
||||
use super::handlers::dedup_handler::{
|
||||
check_hash, check_hashes_batch, get_blob, get_stats, recalculate_stats,
|
||||
};
|
||||
use super::handlers::dedup_handler::{check_hash, check_hashes_batch, get_blob};
|
||||
let dedup_router = Router::new()
|
||||
.route("/check/{hash}", get(check_hash))
|
||||
.route("/check-batch", post(check_hashes_batch))
|
||||
.route("/stats", get(get_stats))
|
||||
.route("/blob/{hash}", get(get_blob))
|
||||
// NOTE: remove_reference is intentionally NOT exposed as a public
|
||||
// endpoint — ref_count management is an internal concern handled
|
||||
// automatically when files are deleted via the file API.
|
||||
.route("/recalculate", post(recalculate_stats))
|
||||
// NOTE: `remove_reference` is intentionally NOT exposed as a
|
||||
// public endpoint — ref_count management is an internal concern
|
||||
// handled automatically when files are deleted via the file API.
|
||||
//
|
||||
// `/stats` and `/recalculate` moved to `/api/admin/dedup/*`
|
||||
// (AuthZ audit #24/#25, 2026-07-17) so the middleware admin
|
||||
// gate covers them by construction. See
|
||||
// `admin_handler::admin_routes()`.
|
||||
.with_state(app_state.clone());
|
||||
|
||||
let mut router = Router::new()
|
||||
|
||||
Reference in New Issue
Block a user