feat(grants): add /api/grants/incoming/resources with a cursor for pagination
This commit is contained in:
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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::<
|
||||
_,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user