Merge pull request #610 from EdouardVanbelle/security/grants2

This commit is contained in:
Dionisio Pozo
2026-07-18 16:07:17 +02:00
committed by GitHub
36 changed files with 1480 additions and 454 deletions
+61 -131
View File
@@ -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},
@@ -27,8 +27,10 @@ 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::admin::require_admin;
use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
use uuid::Uuid;
@@ -89,6 +91,22 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
.route("/plugins/{id}/logs/stream", get(stream_plugin_logs))
.route("/plugins/{id}/retention", get(get_plugin_retention))
.route("/plugins/{id}/retention", put(set_plugin_retention))
// Search — operator flush of the shared moka results cache
// (AuthZ audit #14, 2026-07-16). `invalidate_all()` semantics
// touch every tenant, so this is admin-only. Lived at
// `/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))
@@ -121,14 +139,13 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
)
}
/// 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(
@@ -144,10 +161,7 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, Str
)]
pub async fn get_oidc_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
@@ -175,10 +189,10 @@ pub async fn get_oidc_settings(
)]
pub async fn save_oidc_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
let user_id = auth_user.id;
let svc = state
.admin_settings_service
@@ -200,11 +214,8 @@ pub async fn save_oidc_settings(
/// POST /api/admin/settings/oidc/test — test OIDC discovery
async fn test_oidc_connection(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.admin_settings_service
.as_ref()
@@ -236,10 +247,7 @@ async fn test_oidc_connection(
)]
pub async fn get_storage_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.storage_settings_service
.as_ref()
@@ -267,10 +275,10 @@ pub async fn get_storage_settings(
)]
pub async fn save_storage_settings(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SaveStorageSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
let (user_id, _) = admin_guard(&state, &headers).await?;
let user_id = auth_user.id;
let svc = state
.storage_settings_service
@@ -292,11 +300,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<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<TestStorageConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let svc = state
.storage_settings_service
.as_ref()
@@ -328,9 +333,7 @@ async fn test_storage_connection(
)]
pub async fn get_migration_status(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let s = state.migration_state.read().await;
Ok(Json(migration_state_to_dto(&s)))
}
@@ -350,13 +353,10 @@ pub async fn get_migration_status(
)]
pub async fn start_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<StartMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
admin_guard(&state, &headers).await?;
// Check not already running.
{
let s = state.migration_state.read().await;
@@ -428,10 +428,8 @@ pub async fn start_migration(
)]
pub async fn pause_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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 {
@@ -459,10 +457,8 @@ pub async fn pause_migration(
)]
pub async fn resume_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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;
@@ -491,10 +487,8 @@ pub async fn resume_migration(
)]
pub async fn complete_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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 {
@@ -531,11 +525,8 @@ pub async fn complete_migration(
)]
pub async fn verify_migration(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<VerifyMigrationDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let pool = state
.db_pool
.clone()
@@ -607,12 +598,7 @@ fn migration_state_to_dto(
security(("bearerAuth" = [])),
tag = "admin"
)]
pub async fn generate_encryption_key(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
pub async fn generate_encryption_key() -> Result<impl IntoResponse, AppError> {
let key =
crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key(
);
@@ -670,10 +656,7 @@ fn build_backend_from_config(
)]
pub async fn get_dashboard_stats(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -761,11 +744,8 @@ pub async fn get_dashboard_stats(
)]
pub async fn list_users(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<ListUsersQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -810,11 +790,8 @@ pub async fn list_users(
)]
pub async fn get_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -847,10 +824,10 @@ pub async fn get_user(
)]
pub async fn delete_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
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"))?;
@@ -897,11 +874,11 @@ pub async fn delete_user(
)]
pub async fn update_user_role(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<UpdateUserRoleDto>,
) -> Result<impl IntoResponse, AppError> {
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"))?;
@@ -948,11 +925,11 @@ pub async fn update_user_role(
)]
pub async fn update_user_active(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<UpdateUserActiveDto>,
) -> Result<impl IntoResponse, AppError> {
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"))?;
@@ -1003,12 +980,9 @@ pub async fn update_user_active(
)]
pub async fn update_user_quota(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<UpdateUserQuotaDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -1049,11 +1023,8 @@ pub async fn update_user_quota(
)]
pub async fn create_user(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<AdminCreateUserDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let auth = state
.auth_service
.as_ref()
@@ -1090,12 +1061,9 @@ pub async fn create_user(
)]
pub async fn reset_user_password(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(dto): Json<AdminResetPasswordDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
let auth = state
@@ -1141,10 +1109,10 @@ pub async fn reset_user_password(
)]
pub async fn set_registration_setting(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(body): Json<serde_json::Value>,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let enabled = body
.get("registration_enabled")
@@ -1177,10 +1145,7 @@ pub async fn set_registration_setting(
async fn reextract_audio_metadata(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let audio_service = state
.applications
.audio_metadata_service
@@ -1207,10 +1172,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<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let result = state
.applications
.media_metadata_service
@@ -1253,12 +1215,7 @@ async fn reextract_image_metadata(
security(("bearerAuth" = [])),
tag = "admin"
)]
async fn get_smtp_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
async fn get_smtp_info(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
let smtp = &state.core.config.smtp;
let info = SmtpInfoDto {
enabled: smtp.is_enabled() && state.email_sender.is_some(),
@@ -1287,11 +1244,8 @@ async fn get_smtp_info(
/// returns 404 to keep the endpoint inert.
async fn get_captured_email(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<CapturedEmailQuery>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
if !std::env::var("OXICLOUD_SMTP_MOCK")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
@@ -1347,10 +1301,10 @@ struct CapturedEmailQuery {
)]
async fn send_smtp_test(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Json(dto): Json<SendSmtpTestDto>,
) -> Result<impl IntoResponse, AppError> {
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() {
@@ -1462,9 +1416,7 @@ fn map_mgmt_err(err: &PluginMgmtError) -> AppError {
/// GET /api/admin/plugins — list installed plugins.
pub async fn list_plugins(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let plugins: Vec<PluginInfoDto> = mgmt.list().into_iter().map(PluginInfoDto::from).collect();
// `enabled` reports that the plugin *subsystem* is active (reaching here
@@ -1479,11 +1431,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<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<SetEnabledDto>,
) -> Result<impl IntoResponse, AppError> {
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))?;
@@ -1520,10 +1472,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<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
let (admin_id, _) = admin_guard(&state, &headers).await?;
let admin_id = auth_user.id;
let mgmt = plugin_mgmt(&state)?;
let mut bundle: Option<Vec<u8>> = None;
@@ -1584,10 +1536,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<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
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))?;
@@ -1609,11 +1561,9 @@ pub async fn delete_plugin(
/// structured log entries (newest first).
pub async fn get_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<PluginLogQueryDto>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500);
@@ -1637,10 +1587,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<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
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))?;
@@ -1664,13 +1614,11 @@ pub async fn clear_plugin_logs(
/// so `EventSource` works without setting headers.
pub async fn stream_plugin_logs(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
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"));
@@ -1698,10 +1646,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<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let mgmt = plugin_mgmt(&state)?;
let settings = mgmt
.get_retention(&id)
@@ -1713,11 +1659,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<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<PluginRetentionDto>,
) -> Result<impl IntoResponse, AppError> {
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
@@ -1761,9 +1707,7 @@ pub async fn set_plugin_retention(
)]
pub async fn list_all_drives(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let drives = state
.drive_repo
.list_all()
@@ -1799,10 +1743,8 @@ pub async fn list_all_drives(
)]
pub async fn list_drive_members_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
admin_guard(&state, &headers).await?;
let grants = state
.authorization
.list_grants_on_resource(Resource::Drive(drive_id))
@@ -1862,11 +1804,11 @@ fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
)]
pub async fn add_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
Json(dto): Json<AdminAddDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
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
@@ -1907,7 +1849,7 @@ pub async fn add_drive_member_admin(
)]
pub async fn update_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
@@ -1915,7 +1857,7 @@ pub async fn update_drive_member_admin(
)>,
Json(dto): Json<AdminUpdateDriveMemberDto>,
) -> Result<impl IntoResponse, AppError> {
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
@@ -1954,14 +1896,14 @@ pub async fn update_drive_member_admin(
)]
pub async fn remove_drive_member_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
Uuid,
SubjectTypeDto,
Uuid,
)>,
) -> Result<impl IntoResponse, AppError> {
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
@@ -1996,10 +1938,10 @@ pub async fn remove_drive_member_admin(
)]
pub async fn delete_drive_admin(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
auth_user: AuthUser,
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
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)
@@ -2055,15 +1997,11 @@ fn internal_endpoints_disabled() -> axum::response::Response {
)]
pub async fn internal_trigger_sweep(
State(state): State<Arc<AppState>>,
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 => {
@@ -2135,16 +2073,12 @@ pub struct InternalTriggerGcQuery {
)]
pub async fn internal_trigger_gc(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<InternalTriggerGcQuery>,
) -> 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 {
@@ -2211,16 +2145,12 @@ pub struct InternalTriggerGrantCleanupQuery {
)]
pub async fn internal_trigger_grant_cleanup(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<InternalTriggerGrantCleanupQuery>,
) -> 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
@@ -188,9 +188,14 @@ impl ChunkedUploadHandler {
// ── Permission pre-check: caller must have Create on the target
// folder BEFORE we allocate a session and accept chunks. The
// upload service re-checks at finalize time, but failing here
// avoids wasting client+server resources on chunks that will be
// rejected. None = caller's root namespace, no check needed.
// upload service re-checks at finalize via
// `upload_file_streaming_with_perms` (AuthZ audit #17 fix,
// 2026-07-16) so a grant revoked mid-session is caught. This
// pre-check is the fail-fast: it avoids wasting client+server
// resources on chunks that will be rejected anyway. `None`
// means the write lands at drive-root — that path is currently
// unchecked (session doesn't carry `drive_id`; tracked with the
// folder-id-walking follow-up).
if let Some(ref fid) = request.folder_id
&& let Err(err) = state
.applications
@@ -441,9 +446,17 @@ impl ChunkedUploadHandler {
}
// Register the file row against the ingested blob.
//
// AuthZ audit #17 (2026-07-12): swapped `upload_file_streaming` →
// `upload_file_streaming_with_perms` so `Create` on the target
// folder is re-verified at finalize. Session creation already
// pre-checked (line ~198), but that was potentially hours or
// days ago; app-passwords keep sessions valid indefinitely.
// Without the finalize re-check, a grant revoked mid-session
// stayed effective until the last chunk landed.
let size = ingested.size;
match upload_service
.upload_file_streaming(
.upload_file_streaming_with_perms(
parts.filename.clone(),
parts.folder_id.clone(),
ingested.content_type.clone(),
@@ -478,7 +491,12 @@ impl ChunkedUploadHandler {
}
Err(e) => {
tracing::error!("Failed to create file from chunked upload: {:?}", e);
AppError::internal_error(format!("Failed to create file: {}", e)).into_response()
// AuthZ audit #2 (2026-07-12) — route DomainError through
// `AppError::from` so graduated denial from
// `upload_file_streaming_with_perms` keeps the 403/404
// shape instead of collapsing into a 500. Sibling
// `cancel_upload_impl` at :514 already uses this pattern.
AppError::from(e).into_response()
}
}
}
@@ -519,9 +537,15 @@ impl ChunkedUploadHandler {
// routes.rs calls these free functions directly.
// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue.
/// **Deprecated.** Prefer `/api/files/delta/*` — hash-first negotiation,
/// resumable, chunked. The `/api/uploads/*` family stays for backward
/// compatibility with existing clients but receives no new features.
#[utoipa::path(
post,
path = "/api/uploads",
description = "**Deprecated.** Prefer the delta-upload surface at `/api/files/delta/*` \
(hash-first negotiation, resumable, chunked). The `/api/uploads/*` family is kept for \
backward compatibility with existing clients but is no longer receiving new features.",
request_body(content = CreateUploadRequest, content_type = "application/json", description = "Upload session parameters"),
responses(
(status = 201, description = "Upload session created", body = crate::application::ports::chunked_upload_ports::CreateUploadResponseDto),
@@ -531,6 +555,7 @@ impl ChunkedUploadHandler {
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn create_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -539,9 +564,11 @@ pub async fn create_upload(
ChunkedUploadHandler::create_upload_impl(state, auth_user, request).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
patch,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
("chunk_index" = usize, Query, description = "Zero-based chunk index"),
@@ -570,6 +597,7 @@ pub async fn create_upload(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn upload_chunk(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
@@ -683,9 +711,11 @@ pub async fn upload_chunk(
.into_response()
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
head,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -696,6 +726,7 @@ pub async fn upload_chunk(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn get_upload_status(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -704,9 +735,11 @@ pub async fn get_upload_status(
ChunkedUploadHandler::get_upload_status_impl(state, auth_user, path).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
post,
path = "/api/uploads/{upload_id}/complete",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -731,6 +764,7 @@ pub async fn get_upload_status(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn complete_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
@@ -744,9 +778,11 @@ pub async fn complete_upload(
ChunkedUploadHandler::complete_upload_impl(state, auth_user, path, req).await
}
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
#[utoipa::path(
delete,
path = "/api/uploads/{upload_id}",
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
params(
("upload_id" = String, Path, description = "Upload session ID"),
),
@@ -757,6 +793,7 @@ pub async fn complete_upload(
tag = "uploads",
security(("bearerAuth" = []))
)]
#[deprecated(note = "prefer /api/files/delta/*")]
pub async fn cancel_upload(
state: State<Arc<AppState>>,
auth_user: AuthUser,
+36 -27
View File
@@ -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(
+45 -22
View File
@@ -1,7 +1,7 @@
use axum::{
extract::{Json, Query, State},
http::StatusCode,
response::IntoResponse,
response::{IntoResponse, Response},
};
use serde_json::json;
use tracing::{error, info};
@@ -11,6 +11,7 @@ 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::auth::AuthUser;
use std::sync::Arc;
@@ -187,40 +188,57 @@ impl SearchHandler {
}
}
/// DELETE /search/cache — clears the search results cache.
/// `DELETE /admin/search/cache` — flush the shared moka search
/// results cache. Admin-only.
///
/// 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<Arc<AppState>>,
) -> impl IntoResponse {
auth_user: AuthUser,
) -> Result<Response, AppError> {
let caller_id = auth_user.id;
info!("API: Clearing search cache");
let search_service = match &state.applications.search_service {
Some(service) => service,
None => {
error!("Search service not available");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response();
}
let Some(search_service) = &state.applications.search_service else {
error!("Search service not available");
return Ok((
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "Search service is not available" })),
)
.into_response());
};
match search_service.clear_search_cache().await {
Ok(_) => {
info!("Search cache cleared successfully");
(
tracing::info!(
target: "audit",
event = "search.cache_cleared",
caller_id = %caller_id,
"🧹 search results cache flushed by admin",
);
Ok((
StatusCode::OK,
Json(json!({ "message": "Search cache cleared successfully" })),
)
.into_response()
.into_response())
}
Err(err) => {
error!("Error clearing search cache: {}", err);
(
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Error clearing search cache" })),
)
.into_response()
.into_response())
}
}
}
@@ -368,14 +386,19 @@ pub async fn suggest_files(
#[utoipa::path(
delete,
path = "/api/search/cache",
path = "/api/admin/search/cache",
responses(
(status = 200, description = "Cache cleared"),
(status = 401, description = "Missing or invalid token"),
(status = 403, description = "Caller is not an admin"),
(status = 503, description = "Search service unavailable"),
),
security(("bearerAuth" = [])),
tag = "search"
tag = "admin"
)]
pub async fn clear_search_cache(state: State<Arc<AppState>>) -> impl IntoResponse {
SearchHandler::clear_search_cache_impl(state).await
pub async fn clear_search_cache(
state: State<Arc<AppState>>,
auth_user: AuthUser,
) -> Result<Response, AppError> {
SearchHandler::clear_search_cache_impl(state, auth_user).await
}
+41 -7
View File
@@ -332,23 +332,57 @@ async fn put_file(
};
// ── Atomic store: swap the file row onto the ingested blob ──
// `drive_id` scopes the path-based lookups in `update_file_streaming`
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
// that to the caller's default drive (WOPI today is a single-drive
// editing surface — no drive marker travels in the token).
// `drive_id` scopes the path-based lookups in
// `update_file_streaming_with_perms` post-D0.
//
// AuthZ audit #18 (2026-07-12): the pre-fix path resolved
// `drive_id` via `find_default_for_user(claims_sub_uuid)` —
// ALWAYS the caller's own default personal drive, regardless of
// where the file actually lived. Shared-drive edits either
// misrouted the write into the caller's personal drive (if the
// filename happened to collide with a personal-drive path) or
// 500'd on the parent-folder lookup. Resolve from the file's
// own parent folder instead — one PK probe, returns the drive
// the file genuinely belongs to. Also unlocks shared-drive WOPI
// editing.
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
Ok(u) => u,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
let Some(folder_id_str) = file.folder_id.as_deref() else {
// Files always live under a folder (drive-root files use the
// drive-root folder id). A `None` here means the file entity
// is malformed — safest is a 500.
tracing::error!(
"WOPI PutFile: file {} has no parent folder id — cannot resolve drive",
file_id
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let folder_uuid = match uuid::Uuid::parse_str(folder_id_str) {
Ok(u) => u,
Err(_) => {
tracing::error!(
"WOPI PutFile: file {} parent folder id '{}' is not a UUID",
file_id,
folder_id_str
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let drive_id = match state
.app_state
.drive_repo
.find_default_for_user(claims_sub_uuid)
.drive_id_for_folder(folder_uuid)
.await
{
Ok(d) => d.drive.id,
Ok(id) => id,
Err(e) => {
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
tracing::error!(
"WOPI PutFile: drive-id lookup for folder {} failed: {:?}",
folder_uuid,
e
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};