feat(grants): add /api/grants/incoming/resources with a cursor for pagination

This commit is contained in:
Edouard Vanbelle
2026-05-24 01:05:13 +02:00
parent 50d13943fc
commit 093c1ad3a5
7 changed files with 466 additions and 34 deletions
+52 -1
View File
@@ -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<Grant> 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<String>,
/// Opaque cursor returned by a previous call. Omit to start from the
/// most-recently-granted item.
pub cursor: Option<String>,
}
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<PermissionDto>,
/// Earliest grant date for this resource.
pub granted_at: chrono::DateTime<chrono::Utc>,
/// UUID of the user who created the (earliest) grant.
pub granted_by: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<FileDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub folder: Option<FolderDto>,
}
/// Response for `GET /api/grants/incoming/resources`.
#[derive(Debug, Serialize, ToSchema)]
pub struct SharedWithMeDto {
pub items: Vec<SharedWithMeItemDto>,
/// Opaque cursor for the next page. Absent when the last page is reached.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
+21 -1
View File
@@ -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<Permission>,
) -> Result<Vec<Grant>, 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<GrantCursor>,
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), 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<Vec<Grant>, DomainError>;
+79
View File
@@ -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<chrono::Utc>,
}
// ════════════════════════════════════════════════════════════════════════════
// 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<Self> {
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<Permission>,
/// Earliest `granted_at` across all permission rows.
pub granted_at: chrono::DateTime<chrono::Utc>,
/// 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<chrono::Utc>,
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<Self> {
let bytes = URL_SAFE_NO_PAD.decode(s).ok()?;
serde_json::from_slice(&bytes).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
+104 -1
View File
@@ -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<GrantCursor>,
) -> Result<(Vec<IncomingGrantSummary>, Option<GrantCursor>), DomainError> {
// Build kind filter array — NULL means "all kinds".
let kind_strs: Option<Vec<&str>> = 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<String>,
chrono::DateTime<chrono::Utc>,
Uuid,
);
let rows: Vec<Row> = 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<Row> = 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<Vec<Grant>, DomainError> {
let rows = sqlx::query_as::<
_,
+192 -24
View File
@@ -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<AppState>;
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
@@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser;
tag = "grants"
)]
pub async fn create_grant(
State(authz): State<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Json(dto): Json<CreateGrantDto>,
) -> 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> 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<Option<(Resource, Uuid)>, 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Json(dto): Json<UpdateRoleDto>,
) -> 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> 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<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<SharedWithMeQuery>,
) -> 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<ResourceKind> = 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<String> = file_summaries
.iter()
.map(|s| s.resource_id.to_string())
.collect();
let folder_ids: Vec<String> = 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<SharedWithMeItemDto> = 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
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<GrantDto> = 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<Arc<PgAclEngine>>,
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<OnResourceQuery>,
) -> impl IntoResponse {
let authz = &state.authorization;
let caller_id = auth_user.id;
let resource: Resource = ResourceDto {
kind: q.resource_type,
+7 -4
View File
@@ -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(
+11 -3
View File
@@ -165,7 +165,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
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<AppState>) -> Router<Arc<AppState>> {
Router::new()
};
// Create routes for ReBAC grants (/api/grants) — single state: the authz engine.
// Create routes for ReBAC grants (/api/grants).
// State is Arc<AppState> so that the new list_shared_with_me handler can
// access file/folder services. Existing handlers still extract
// State<Arc<PgAclEngine>> 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<AppState>) -> Router<Arc<AppState>> {
.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