feat: auth hardening + codebase quality sweep

Refresh tokens: RefreshToken entity, PostgresRefreshTokenRepository,
login returns refresh token, POST /auth/refresh (rotation), POST /auth/logout,
JWT expiry 24h→1h, configurable via with_expiry().

Route protection: require_auth middleware on protected routes,
public routes split (register, login, refresh, sharing/access).

Authorization: caller_id added to ReadAssetFileQuery, ReadDerivativeQuery,
GetStackQuery, DeleteStackCommand with ownership checks. Admin-only gates
on processing, storage, sidecar, duplicates handlers.

Quality fixes: visibility filtering bypass in search(), unwrap panics in
date parsing, DRY auth header parsing, centralized parsers module,
email validation via email_address crate, value objects (Username, MimeType,
RelativePath), domain events (UserCreated, UserDeleted, AlbumCreated,
TagCreated, DuplicateDetected), postgres error mapping for constraint
violations, OptionExt::or_not_found helper, in_memory_repo! macro,
GetStackQuery moved to queries, album add_entry 200→201.
This commit is contained in:
2026-05-31 22:26:02 +02:00
parent 84fb410316
commit c6f82090d2
71 changed files with 2311 additions and 563 deletions

View File

@@ -1,49 +1,28 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{
requests::{
CompleteJobRequest, ConfigurePipelineRequest, EnqueueJobRequest, FailJobRequest,
ManagePluginRequest,
},
responses::{BatchProgressResponse, JobResponse, PipelineResponse, PluginResponse},
responses::{
BatchProgressResponse, JobListResponse, JobResponse, PipelineResponse, PluginResponse,
},
};
use application::processing::{
CompleteJobCommand, ConfigurePipelineCommand, EnqueueJobCommand, FailJobCommand,
CompleteJobCommand, ConfigurePipelineCommand, EnqueueJobCommand, FailJobCommand, ListJobsQuery,
ManagePluginCommand, PipelineStepConfig, PluginAction, ReportBatchProgressQuery,
StartJobCommand,
};
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
};
use domain::{
entities::{JobType, PluginType},
errors::DomainError,
value_objects::{MetadataValue, StructuredData, SystemId},
};
fn parse_job_type(s: &str) -> JobType {
match s {
"scan_directory" => JobType::ScanDirectory,
"extract_metadata" => JobType::ExtractMetadata,
"generate_derivative" => JobType::GenerateDerivative,
"sync_sidecar" => JobType::SyncSidecar,
"detect_duplicates" => JobType::DetectDuplicates,
other => JobType::Custom(other.to_string()),
}
}
fn parse_plugin_type(s: &str) -> Result<PluginType, AppError> {
match s {
"media_processor" => Ok(PluginType::MediaProcessor),
"scheduled_task" => Ok(PluginType::ScheduledTask),
"sidecar_writer" => Ok(PluginType::SidecarWriter),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid plugin type: {s}"
)))),
}
}
fn hashmap_to_structured(
map: &std::collections::HashMap<String, serde_json::Value>,
) -> StructuredData {
@@ -54,11 +33,38 @@ fn hashmap_to_structured(
sd
}
#[derive(Debug, serde::Deserialize)]
pub struct ListJobsParams {
pub status: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn list_jobs(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<ListJobsParams>,
) -> Result<Json<JobListResponse>, AppError> {
super::require_admin(&claims)?;
let query = ListJobsQuery {
status: params.status,
limit: params.limit.unwrap_or(20).min(100),
offset: params.offset.unwrap_or(0),
};
let result = state.processing.list_jobs.execute(query).await?;
let jobs = result.jobs.iter().map(JobResponse::from_domain).collect();
Ok(Json(JobListResponse {
jobs,
total: result.total,
}))
}
pub async fn enqueue_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<EnqueueJobRequest>,
) -> Result<(StatusCode, Json<JobResponse>), AppError> {
super::require_admin(&claims)?;
let payload = req
.payload
.as_ref()
@@ -66,7 +72,7 @@ pub async fn enqueue_job(
.unwrap_or_default();
let cmd = EnqueueJobCommand {
job_type: parse_job_type(&req.job_type),
job_type: parsers::job_type(&req.job_type),
priority: req.priority.unwrap_or(0),
payload,
target_asset_id: req.target_asset_id.map(SystemId::from_uuid),
@@ -78,9 +84,10 @@ pub async fn enqueue_job(
pub async fn start_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((job_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<JobResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = StartJobCommand {
job_id: SystemId::from_uuid(job_id),
};
@@ -90,10 +97,11 @@ pub async fn start_job(
pub async fn complete_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((job_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<CompleteJobRequest>,
) -> Result<Json<JobResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = CompleteJobCommand {
job_id: SystemId::from_uuid(job_id),
result: hashmap_to_structured(&req.result),
@@ -104,10 +112,11 @@ pub async fn complete_job(
pub async fn fail_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((job_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<FailJobRequest>,
) -> Result<Json<JobResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = FailJobCommand {
job_id: SystemId::from_uuid(job_id),
error: req.error,
@@ -118,9 +127,10 @@ pub async fn fail_job(
pub async fn batch_progress(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((batch_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<BatchProgressResponse>, AppError> {
super::require_admin(&claims)?;
let query = ReportBatchProgressQuery {
batch_id: SystemId::from_uuid(batch_id),
};
@@ -130,16 +140,17 @@ pub async fn batch_progress(
pub async fn manage_plugin(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<ManagePluginRequest>,
) -> Result<(StatusCode, Json<PluginResponse>), AppError> {
super::require_admin(&claims)?;
let action = match req.action.as_str() {
"create" => {
let name = req.name.ok_or_else(|| {
AppError::from(DomainError::Validation("name required for create".into()))
})?;
let pt = req.plugin_type.as_deref().unwrap_or("media_processor");
let plugin_type = parse_plugin_type(pt)?;
let plugin_type = parsers::plugin_type(pt)?;
let config = req
.config
.as_ref()
@@ -173,9 +184,10 @@ pub async fn manage_plugin(
pub async fn configure_pipeline(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<ConfigurePipelineRequest>,
) -> Result<(StatusCode, Json<PipelineResponse>), AppError> {
super::require_admin(&claims)?;
let steps = req
.steps
.iter()