From 093c1ad3a57f780cd3f145613f9bccee56554886 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 24 May 2026 01:05:13 +0200 Subject: [PATCH] feat(grants): add /api/grants/incoming/resources with a cursor for pagination --- src/application/dtos/grant_dto.rs | 53 ++++- src/application/ports/authorization_ports.rs | 22 +- src/domain/services/authorization.rs | 79 +++++++ src/infrastructure/services/pg_acl_engine.rs | 105 ++++++++- src/interfaces/api/handlers/grant_handler.rs | 216 ++++++++++++++++--- src/interfaces/api/mod.rs | 11 +- src/interfaces/api/routes.rs | 14 +- 7 files changed, 466 insertions(+), 34 deletions(-) diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index d45f01ae..71d0d8de 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -5,9 +5,11 @@ //! storage-agnostic and DTOs can evolve with the HTTP contract. use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::folder_dto::FolderDto; use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; // ════════════════════════════════════════════════════════════════════════════ @@ -220,3 +222,52 @@ impl From for GrantDto { } } } + +// ════════════════════════════════════════════════════════════════════════════ +// Shared-with-me DTOs (GET /api/grants/incoming/resources) +// ════════════════════════════════════════════════════════════════════════════ + +/// Query parameters for `GET /api/grants/incoming/resources`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct SharedWithMeQuery { + /// Maximum number of items to return (1–200, default 50). + #[serde(default = "shared_with_me_default_limit")] + pub limit: u32, + /// Comma-separated resource types to include, e.g. `file,folder`. + /// Omit to return all known types. + pub resource_types: Option, + /// Opaque cursor returned by a previous call. Omit to start from the + /// most-recently-granted item. + pub cursor: Option, +} + +fn shared_with_me_default_limit() -> u32 { + 50 +} + +/// One item in the shared-with-me list. Exactly one of `file` / `folder` is +/// populated, indicated by `resource_type`. Additional optional fields for +/// future resource types (playlist, addressbook, …) will be added here. +#[derive(Debug, Serialize, ToSchema)] +pub struct SharedWithMeItemDto { + pub resource_type: ResourceTypeDto, + /// All permissions the caller holds on this resource (aggregated). + pub permissions: Vec, + /// Earliest grant date for this resource. + pub granted_at: chrono::DateTime, + /// UUID of the user who created the (earliest) grant. + pub granted_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub folder: Option, +} + +/// Response for `GET /api/grants/incoming/resources`. +#[derive(Debug, Serialize, ToSchema)] +pub struct SharedWithMeDto { + pub items: Vec, + /// Opaque cursor for the next page. Absent when the last page is reached. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 941d4bd5..2e46dddb 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -11,7 +11,9 @@ use uuid::Uuid; use crate::common::errors::DomainError; -use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; +use crate::domain::services::authorization::{ + Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, +}; pub trait AuthorizationEngine: Send + Sync + 'static { /// Returns true if `subject` has `permission` on `resource`, considering @@ -67,6 +69,24 @@ pub trait AuthorizationEngine: Send + Sync + 'static { permission_filter: Option, ) -> Result, DomainError>; + /// Cursor-paginated list of resources explicitly granted to `subject`, + /// optionally filtered by resource kind. Multiple permission rows for the + /// same resource are collapsed into one `IncomingGrantSummary`. + /// + /// Ordered by `MIN(granted_at) DESC, resource_id DESC` — stable across + /// concurrent inserts because the cursor encodes both fields. + /// + /// Pass `kinds = &[]` to return all resource kinds. + /// Returns `(summaries, next_cursor)` — `next_cursor` is `None` when the + /// last page has been reached. + async fn list_incoming_resources_paged( + &self, + subject: Subject, + kinds: &[ResourceKind], + limit: u32, + cursor: Option, + ) -> Result<(Vec, Option), DomainError>; + /// All grants on a specific resource (for "Manage sharing" UI). Caller /// must verify the caller has `Share` on the resource before invoking. async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError>; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 42a03f02..939dfdeb 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -5,6 +5,7 @@ //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation //! maps them to / from `storage.access_grants` rows. +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use std::fmt; use uuid::Uuid; @@ -201,6 +202,84 @@ pub struct Grant { pub granted_at: chrono::DateTime, } +// ════════════════════════════════════════════════════════════════════════════ +// ResourceKind — type-only discriminator (no id), used for filtering queries +// ════════════════════════════════════════════════════════════════════════════ + +/// Resource type without an id — used to filter paginated grant queries by +/// type. Mirrors the `resource_type` column values in `storage.access_grants`. +/// Add new variants here when new resource types are supported. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ResourceKind { + File, + Folder, + // Future: Calendar, AddressBook, Playlist, … +} + +impl ResourceKind { + pub fn as_str(&self) -> &'static str { + match self { + ResourceKind::File => "file", + ResourceKind::Folder => "folder", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "file" => Some(ResourceKind::File), + "folder" => Some(ResourceKind::Folder), + _ => None, + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// IncomingGrantSummary — aggregated across multiple permission rows +// ════════════════════════════════════════════════════════════════════════════ + +/// Multiple `access_grants` rows for the same `(subject, resource)` collapsed +/// into one record. Used by `list_incoming_resources_paged` to avoid sending +/// duplicate resource items to the caller. +#[derive(Debug, Clone)] +pub struct IncomingGrantSummary { + pub resource_type: ResourceKind, + pub resource_id: Uuid, + /// All permissions held on this resource (aggregated). + pub permissions: Vec, + /// Earliest `granted_at` across all permission rows. + pub granted_at: chrono::DateTime, + /// Granter of the earliest grant. + pub granted_by: Uuid, +} + +// ════════════════════════════════════════════════════════════════════════════ +// GrantCursor — opaque pagination cursor for list_incoming_resources_paged +// ════════════════════════════════════════════════════════════════════════════ + +/// Encodes the position of the last seen item in a cursor-paginated grant +/// listing. The encoding is opaque to API callers — only the backend +/// decodes it. Change the encoding algorithm in a major version bump. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GrantCursor { + pub granted_at: chrono::DateTime, + pub resource_id: Uuid, +} + +impl GrantCursor { + /// Encode as a URL-safe base64 JSON string (no padding). + pub fn encode(&self) -> String { + let json = serde_json::to_vec(self).unwrap_or_default(); + URL_SAFE_NO_PAD.encode(&json) + } + + /// Decode from a URL-safe base64 JSON string. Returns `None` on any + /// parse failure — callers treat a bad cursor as "start from the top". + pub fn decode(s: &str) -> Option { + let bytes = URL_SAFE_NO_PAD.decode(s).ok()?; + serde_json::from_slice(&bytes).ok() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 6711b110..34d1be13 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -35,7 +35,9 @@ use sqlx::PgPool; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; -use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; +use crate::domain::services::authorization::{ + Grant, GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject, +}; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; @@ -291,6 +293,107 @@ impl AuthorizationEngine for PgAclEngine { rows.into_iter().map(Self::row_to_grant).collect() } + async fn list_incoming_resources_paged( + &self, + subject: Subject, + kinds: &[ResourceKind], + limit: u32, + cursor: Option, + ) -> Result<(Vec, Option), DomainError> { + // Build kind filter array — NULL means "all kinds". + let kind_strs: Option> = if kinds.is_empty() { + None + } else { + Some(kinds.iter().map(|k| k.as_str()).collect()) + }; + + let cursor_at = cursor.as_ref().map(|c| c.granted_at); + let cursor_id = cursor.as_ref().map(|c| c.resource_id); + + // Fetch limit+1 rows so we can detect whether a next page exists. + let fetch_limit = (limit as i64) + 1; + + // Each row: (resource_type, resource_id, permissions_text_array, + // granted_at, granted_by) + type Row = ( + String, + Uuid, + Vec, + chrono::DateTime, + Uuid, + ); + + let rows: Vec = sqlx::query_as( + r#" + WITH agg AS ( + SELECT + resource_type, + resource_id, + array_agg(DISTINCT permission ORDER BY permission) AS permissions, + MIN(granted_at) AS granted_at, + (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by + FROM storage.access_grants + WHERE subject_type = $1 + AND subject_id = $2 + AND ($3::text[] IS NULL OR resource_type = ANY($3)) + GROUP BY resource_type, resource_id + ) + SELECT resource_type, resource_id, permissions, granted_at, granted_by + FROM agg + WHERE ( $4::timestamptz IS NULL + OR granted_at < $4 + OR (granted_at = $4 AND resource_id < $5::uuid)) + ORDER BY granted_at DESC, resource_id DESC + LIMIT $6 + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(kind_strs) + .bind(cursor_at) + .bind(cursor_id) + .bind(fetch_limit) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("list_incoming_resources_paged: {e}")) + })?; + + let has_next = rows.len() > limit as usize; + let rows: Vec = rows.into_iter().take(limit as usize).collect(); + + // Determine the next cursor from the last item we're actually returning. + let next_cursor = if has_next { + rows.last().map(|r| GrantCursor { + granted_at: r.3, + resource_id: r.1, + }) + } else { + None + }; + + // Convert rows into domain summaries. + let summaries = rows + .into_iter() + .filter_map(|(rt, rid, perms_str, granted_at, granted_by)| { + let resource_type = ResourceKind::parse(&rt)?; + let permissions = perms_str + .iter() + .filter_map(|s| Permission::parse(s)) + .collect(); + Some(IncomingGrantSummary { + resource_type, + resource_id: rid, + permissions, + granted_at, + granted_by, + }) + }) + .collect(); + + Ok((summaries, next_cursor)) + } + async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError> { let rows = sqlx::query_as::< _, diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index b769fa7c..d379136f 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -11,23 +11,32 @@ use axum::{ http::StatusCode, response::IntoResponse, }; +use futures::future::join_all; use serde::Deserialize; use std::sync::Arc; -use tracing::{error, info}; +use tracing::{error, info, warn}; use utoipa::IntoParams; use uuid::Uuid; use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto, - UpdateRoleDto, + CreateGrantDto, GrantDto, PermissionDto, 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::services::authorization::{Permission, Resource, Subject}; -use crate::infrastructure::services::pg_acl_engine::PgAclEngine; +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 // ════════════════════════════════════════════════════════════════════════════ @@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser; tag = "grants" )] pub async fn create_grant( - State(authz): State>, + 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 @@ -118,10 +128,11 @@ pub async fn create_grant( tag = "grants" )] pub async fn revoke_grant( - State(authz): State>, + 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, @@ -129,7 +140,7 @@ pub async fn revoke_grant( }; // Look up the grant to find the underlying resource (and granter). - let on_resource = match find_grant_resource(&authz, grant_id).await { + 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(), @@ -151,16 +162,6 @@ pub async fn revoke_grant( StatusCode::NO_CONTENT.into_response() } -/// Look up a grant by id and return (resource, granted_by) so the caller-auth -/// check in revoke_grant can determine if the caller is the granter or needs -/// the Share permission on the resource. Returns `Ok(None)` if no such grant. -async fn find_grant_resource( - authz: &PgAclEngine, - grant_id: Uuid, -) -> Result, DomainError> { - authz.find_grant_by_id(grant_id).await -} - // ════════════════════════════════════════════════════════════════════════════ // PUT /api/grants/role // ════════════════════════════════════════════════════════════════════════════ @@ -176,10 +177,11 @@ async fn find_grant_resource( tag = "grants" )] pub async fn set_role( - State(authz): State>, + 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(); @@ -262,12 +264,13 @@ pub struct IncomingQuery { tag = "grants" )] pub async fn list_incoming( - State(authz): State>, + State(state): State, auth_user: AuthUser, Query(q): Query, ) -> impl IntoResponse { let caller_id = auth_user.id; - match authz + match state + .authorization .list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into)) .await { @@ -279,6 +282,170 @@ pub async fn list_incoming( } } +// ════════════════════════════════════════════════════════════════════════════ +// 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), + ), + 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.clamp(1, 200); + + // Decode cursor (treat invalid cursor as "start from top"). + let cursor = q.cursor.as_deref().and_then(GrantCursor::decode); + + // Fetch paged summaries from the ACL engine. + let (summaries, next_cursor) = match state + .authorization + .list_incoming_resources_paged(subject, &kinds, limit, cursor) + .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, + file: Some(file_dto.clone()), + folder: None, + }); + } + 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, + file: None, + folder: Some(folder_dto.clone()), + }); + } + 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 { + items, + next_cursor: next_cursor.map(|c| c.encode()), + }), + ) + .into_response() +} + // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants/outgoing // ════════════════════════════════════════════════════════════════════════════ @@ -292,11 +459,11 @@ pub async fn list_incoming( tag = "grants" )] pub async fn list_outgoing( - State(authz): State>, + State(state): State, auth_user: AuthUser, ) -> impl IntoResponse { let caller_id = auth_user.id; - match authz.list_outgoing_grants(caller_id).await { + 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() @@ -327,10 +494,11 @@ pub struct OnResourceQuery { tag = "grants" )] pub async fn list_on_resource( - State(authz): State>, + 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, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 320d961f..11ed606e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -20,6 +20,10 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::folder_listing_dto::FolderListingDto; +use crate::application::dtos::grant_dto::{ + CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, + SharedWithMeItemDto, SubjectDto, SubjectTypeDto, UpdateRoleDto, +}; use crate::application::dtos::i18n_dto::{ LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto, }; @@ -52,10 +56,6 @@ use crate::interfaces::api::handlers::contacts_handler::{ use crate::interfaces::api::handlers::dedup_handler::{ DedupUploadResponse, HashCheckResponse, StatsResponse, }; -use crate::application::dtos::grant_dto::{ - CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, Role, SubjectDto, - SubjectTypeDto, UpdateRoleDto, -}; use crate::interfaces::api::handlers::file_handler::MoveFilePayload; #[derive(OpenApi)] @@ -211,6 +211,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::grant_handler::revoke_grant, handlers::grant_handler::set_role, handlers::grant_handler::list_incoming, + handlers::grant_handler::list_shared_with_me, handlers::grant_handler::list_outgoing, handlers::grant_handler::list_on_resource, ), @@ -296,6 +297,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; CreateGrantDto, UpdateRoleDto, GrantDto, + SharedWithMeDto, + SharedWithMeItemDto, ) ), tags( diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 872ec605..ebf65ec5 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -165,7 +165,8 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { let share_service = app_state.share_service.clone(); let favorites_service = app_state.favorites_service.clone(); let recent_service = app_state.recent_service.clone(); - let authorization = app_state.authorization.clone(); + // authorization is no longer extracted separately — the grants router now + // uses app_state directly so handlers can access all services. // Initialize the batch operations service let mut batch_service_builder = BatchOperationService::default( @@ -302,7 +303,10 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { Router::new() }; - // Create routes for ReBAC grants (/api/grants) — single state: the authz engine. + // Create routes for ReBAC grants (/api/grants). + // State is Arc so that the new list_shared_with_me handler can + // access file/folder services. Existing handlers still extract + // State> via the FromRef impl in di.rs. let grants_router = { use crate::interfaces::api::handlers::grant_handler; Router::new() @@ -311,8 +315,12 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/{id}", delete(grant_handler::revoke_grant)) .route("/role", put(grant_handler::set_role)) .route("/incoming", get(grant_handler::list_incoming)) + .route( + "/incoming/resources", + get(grant_handler::list_shared_with_me), + ) .route("/outgoing", get(grant_handler::list_outgoing)) - .with_state(authorization.clone()) + .with_state(app_state.clone()) }; // Create a router without the i18n routes