//! REST handlers for the ReBAC grant management endpoints. //! //! All endpoints under `/api/grants`. The authenticated caller is taken from //! the `AuthUser` extractor. Authorization for sharing operations is enforced //! via `authz.require(caller, Share, resource)` — handlers never embed their //! own checks (see CLAUDE.md § Authorization). use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, }; use futures::future::join_all; use serde::Deserialize; use std::sync::Arc; use tracing::{error, info, warn}; use utoipa::IntoParams; use uuid::Uuid; use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::folder_ports::FolderUseCase; use crate::common::di::AppState; #[allow(unused_imports)] use crate::common::errors::DomainError; use crate::domain::errors::ErrorKind; use crate::domain::services::authorization::{ GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, }; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; type AppStateRef = Arc; // ════════════════════════════════════════════════════════════════════════════ // POST /api/grants // ════════════════════════════════════════════════════════════════════════════ #[utoipa::path( post, path = "/api/grants", request_body = CreateGrantDto, responses( (status = 201, description = "Grant(s) created", body = Vec), (status = 400, description = "Invalid input (both/neither of permissions+role provided)"), (status = 404, description = "Resource not found OR caller lacks Share permission"), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn create_grant( State(state): State, auth_user: AuthUser, Json(dto): Json, ) -> impl IntoResponse { let authz = &state.authorization; let caller_id = auth_user.id; // Validate: exactly one of permissions/role let permissions: Vec = match (dto.permissions, dto.role) { (Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(), (None, Some(role)) => role.expand().to_vec(), (Some(_), Some(_)) => { return AppError::new( StatusCode::BAD_REQUEST, "Provide either 'permissions' or 'role', not both", "InvalidInput", ) .into_response(); } _ => { return AppError::new( StatusCode::BAD_REQUEST, "Either 'permissions' (non-empty) or 'role' is required", "InvalidInput", ) .into_response(); } }; let subject: Subject = dto.subject.into(); let resource: Resource = dto.resource.into(); // Caller must have Share on the resource (owners pass via short-circuit). if let Err(e) = authz .require(Subject::User(caller_id), Permission::Share, resource) .await { return AppError::from(e).into_response(); } let mut results: Vec = Vec::with_capacity(permissions.len()); for perm in permissions { match authz.grant(caller_id, subject, perm, resource).await { Ok(grant) => results.push(grant.into()), Err(err) => { error!("grant insert failed for {perm:?}: {err}"); return AppError::from(err).into_response(); } } } info!( "Created {} grant(s) for subject={:?} on resource={:?} by user {}", results.len(), subject, resource, caller_id ); (StatusCode::CREATED, Json(results)).into_response() } // ════════════════════════════════════════════════════════════════════════════ // DELETE /api/grants/{id} // ════════════════════════════════════════════════════════════════════════════ #[utoipa::path( delete, path = "/api/grants/{id}", params(("id" = String, Path, description = "Grant UUID")), responses( (status = 204, description = "Grant revoked (or did not exist)"), (status = 404, description = "Caller lacks Share permission on the underlying resource"), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn revoke_grant( State(state): State, auth_user: AuthUser, Path(id): Path, ) -> impl IntoResponse { let authz = &state.authorization; let caller_id = auth_user.id; let grant_id = match Uuid::parse_str(&id) { Ok(u) => u, Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(), }; // Look up the grant to find the underlying resource (and granter). let on_resource = match authz.find_grant_by_id(grant_id).await { Ok(Some((res, granter))) => (res, granter), Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent Err(e) => return AppError::from(e).into_response(), }; // Caller is authorized if they are the granter OR have Share on the resource. if on_resource.1 != caller_id && let Err(e) = authz .require(Subject::User(caller_id), Permission::Share, on_resource.0) .await { return AppError::from(e).into_response(); } if let Err(e) = authz.revoke(grant_id).await { return AppError::from(e).into_response(); } info!("Revoked grant {grant_id} (caller {caller_id})"); StatusCode::NO_CONTENT.into_response() } // ════════════════════════════════════════════════════════════════════════════ // PUT /api/grants/role // ════════════════════════════════════════════════════════════════════════════ #[utoipa::path( put, path = "/api/grants/role", request_body = UpdateRoleDto, responses( (status = 200, description = "Role applied; returns the new full grant set", body = Vec), (status = 404, description = "Resource not found or caller lacks Share"), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn set_role( State(state): State, auth_user: AuthUser, Json(dto): Json, ) -> impl IntoResponse { let authz = &state.authorization; let caller_id = auth_user.id; let subject: Subject = dto.subject.into(); let resource: Resource = dto.resource.into(); let target_perms: std::collections::HashSet = dto.role.expand().iter().copied().collect(); // Caller must have Share on the resource. if let Err(e) = authz .require(Subject::User(caller_id), Permission::Share, resource) .await { return AppError::from(e).into_response(); } // Fetch current grants on the resource for this subject. let current = match authz.list_grants_on_resource(resource).await { Ok(g) => g, Err(e) => return AppError::from(e).into_response(), }; let current_perms: std::collections::HashSet = current .iter() .filter(|g| g.subject == subject) .map(|g| g.permission) .collect(); // Diff and apply. let to_add: Vec = target_perms.difference(¤t_perms).copied().collect(); let to_remove: Vec = current_perms.difference(&target_perms).copied().collect(); for perm in &to_remove { if let Some(g) = current .iter() .find(|g| g.subject == subject && g.permission == *perm) && let Err(e) = authz.revoke(g.id).await { return AppError::from(e).into_response(); } } for perm in &to_add { if let Err(e) = authz.grant(caller_id, subject, *perm, resource).await { return AppError::from(e).into_response(); } } // Return the new full set. let after = match authz.list_grants_on_resource(resource).await { Ok(g) => g, Err(e) => return AppError::from(e).into_response(), }; let mine: Vec = after .into_iter() .filter(|g| g.subject == subject) .map(Into::into) .collect(); info!( "Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}", caller_id, subject, resource, to_add, to_remove ); (StatusCode::OK, Json(mine)).into_response() } // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants/incoming // ════════════════════════════════════════════════════════════════════════════ #[derive(Debug, Deserialize, IntoParams)] pub struct IncomingQuery { #[serde(default)] pub permission: Option, } #[utoipa::path( get, path = "/api/grants/incoming", params(IncomingQuery), responses( (status = 200, description = "Direct grants targeting the caller", body = Vec), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn list_incoming( State(state): State, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { let caller_id = auth_user.id; match state .authorization .list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into)) .await { Ok(grants) => { let dtos: Vec = grants.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(dtos)).into_response() } Err(e) => AppError::from(e).into_response(), } } // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants/incoming/resources // ════════════════════════════════════════════════════════════════════════════ #[utoipa::path( get, path = "/api/grants/incoming/resources", params(SharedWithMeQuery), responses( (status = 200, description = "Cursor-paginated resources shared with the caller. \ Each item carries the full file or folder details plus \ aggregated permissions. `next_cursor` is absent on the \ last page.", body = SharedWithMeDto), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn list_shared_with_me( State(state): State, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { let caller_id = auth_user.id; let subject = Subject::User(caller_id); // Parse resource_types filter (unknown values silently ignored). let kinds: Vec = q .resource_types .as_deref() .map(|s| { s.split(',') .filter_map(|t| ResourceKind::parse(t.trim())) .collect() }) .unwrap_or_default(); // Clamp limit to 1–200. let limit = q.limit_clamped() as u32; // Validate sort_by (defaults to "granted_at"). let sort_by = q.sort_by.as_deref().unwrap_or("granted_at"); if !matches!( sort_by, "granted_at" | "granted_by" | "name" | "type" | "size" ) { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})), ) .into_response(); } let reverse = q.reverse; // Decode cursor — discard it when the sort dimension or direction changed // to avoid keyset confusion across sort modes. let cursor = q .decode_cursor::() .filter(|c| c.sort_by == sort_by && c.reverse == reverse); // Fetch paged summaries from the ACL engine. let (summaries, next_cursor) = match state .authorization .list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by, reverse) .await { Ok(r) => r, Err(e) => return AppError::from(e).into_response(), }; // Split summaries by resource kind for parallel resolution. let file_summaries: Vec<&IncomingGrantSummary> = summaries .iter() .filter(|s| matches!(s.resource_type, ResourceKind::File)) .collect(); let folder_summaries: Vec<&IncomingGrantSummary> = summaries .iter() .filter(|s| matches!(s.resource_type, ResourceKind::Folder)) .collect(); let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service_concrete; // Pre-compute ID strings to avoid temporaries inside async closures. let file_ids: Vec = file_summaries .iter() .map(|s| s.resource_id.to_string()) .collect(); let folder_ids: Vec = folder_summaries .iter() .map(|s| s.resource_id.to_string()) .collect(); // Resolve resource details concurrently (files and folders in parallel). let (file_results, folder_results) = tokio::join!( join_all(file_ids.iter().map(|id| file_service.get_file(id))), join_all(folder_ids.iter().map(|id| folder_service.get_folder(id))) ); // Build the unified item list in original grant order (newest first). // We iterate summaries in order and pick the resolved result from the // appropriate typed bucket. let mut file_idx = 0usize; let mut folder_idx = 0usize; let mut items: Vec = Vec::with_capacity(summaries.len()); for summary in &summaries { match summary.resource_type { ResourceKind::File => { let result = &file_results[file_idx]; file_idx += 1; match result { Ok(file_dto) => { items.push(SharedWithMeItemDto { resource_type: ResourceTypeDto::File, permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), granted_at: summary.granted_at, granted_by: summary.granted_by, resource: ResourceContentDto::File( file_dto.clone().without_hierarchy_info(), ), }); } Err(e) if e.kind == ErrorKind::NotFound => { // Stale grant (file deleted, trigger not yet fired) — skip silently. warn!( "Skipping stale file grant for resource_id={}: not found", summary.resource_id ); } Err(e) => { return AppError::internal_error(format!( "Failed to fetch file {}: {e}", summary.resource_id )) .into_response(); } } } ResourceKind::Folder => { let result = &folder_results[folder_idx]; folder_idx += 1; match result { Ok(folder_dto) => { items.push(SharedWithMeItemDto { resource_type: ResourceTypeDto::Folder, permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), granted_at: summary.granted_at, granted_by: summary.granted_by, resource: ResourceContentDto::Folder( folder_dto.clone().without_hierarchy_info(), ), }); } Err(e) if e.kind == ErrorKind::NotFound => { warn!( "Skipping stale folder grant for resource_id={}: not found", summary.resource_id ); } Err(e) => { return AppError::internal_error(format!( "Failed to fetch folder {}: {e}", summary.resource_id )) .into_response(); } } } } } ( StatusCode::OK, Json(SharedWithMeDto::with_cursor( items, next_cursor.map(|c| c.encode()), )), ) .into_response() } // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants/outgoing // ════════════════════════════════════════════════════════════════════════════ #[utoipa::path( get, path = "/api/grants/outgoing", responses( (status = 200, description = "Grants the caller has created", body = Vec), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn list_outgoing( State(state): State, auth_user: AuthUser, ) -> impl IntoResponse { let caller_id = auth_user.id; match state.authorization.list_outgoing_grants(caller_id).await { Ok(grants) => { let dtos: Vec = grants.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(dtos)).into_response() } Err(e) => AppError::from(e).into_response(), } } // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants?resource_type=...&resource_id=... // (list grants on a specific resource — requires Share on it) // ════════════════════════════════════════════════════════════════════════════ #[derive(Debug, Deserialize, IntoParams)] pub struct OnResourceQuery { pub resource_type: ResourceTypeDto, pub resource_id: Uuid, } #[utoipa::path( get, path = "/api/grants", params(OnResourceQuery), responses( (status = 200, description = "Grants on the specified resource", body = Vec), (status = 404, description = "Resource not found or caller lacks Share"), ), security(("bearerAuth" = [])), tag = "grants" )] pub async fn list_on_resource( State(state): State, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { let authz = &state.authorization; let caller_id = auth_user.id; let resource: Resource = ResourceDto { kind: q.resource_type, id: q.resource_id, } .into(); if let Err(e) = authz .require(Subject::User(caller_id), Permission::Share, resource) .await { return AppError::from(e).into_response(); } match authz.list_grants_on_resource(resource).await { Ok(grants) => { let dtos: Vec = grants.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(dtos)).into_response() } Err(e) => AppError::from(e).into_response(), } } // Silence unused-import warnings for SubjectDto when only certain endpoints // touch it directly. #[allow(dead_code)] fn _ensure_subject_dto_compiles(_: SubjectDto) {}