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.
|
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::{IntoParams, ToSchema};
|
||||||
use uuid::Uuid;
|
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};
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use crate::common::errors::DomainError;
|
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 {
|
pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||||
/// Returns true if `subject` has `permission` on `resource`, considering
|
/// Returns true if `subject` has `permission` on `resource`, considering
|
||||||
@@ -67,6 +69,24 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
|||||||
permission_filter: Option<Permission>,
|
permission_filter: Option<Permission>,
|
||||||
) -> Result<Vec<Grant>, DomainError>;
|
) -> 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
|
/// All grants on a specific resource (for "Manage sharing" UI). Caller
|
||||||
/// must verify the caller has `Share` on the resource before invoking.
|
/// must verify the caller has `Share` on the resource before invoking.
|
||||||
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError>;
|
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
|
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||||
//! maps them to / from `storage.access_grants` rows.
|
//! maps them to / from `storage.access_grants` rows.
|
||||||
|
|
||||||
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -201,6 +202,84 @@ pub struct Grant {
|
|||||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ use sqlx::PgPool;
|
|||||||
|
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||||
use crate::common::errors::DomainError;
|
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::file_blob_read_repository::FileBlobReadRepository;
|
||||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
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()
|
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> {
|
async fn list_grants_on_resource(&self, resource: Resource) -> Result<Vec<Grant>, DomainError> {
|
||||||
let rows = sqlx::query_as::<
|
let rows = sqlx::query_as::<
|
||||||
_,
|
_,
|
||||||
|
|||||||
@@ -11,23 +11,32 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
use futures::future::join_all;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
use utoipa::IntoParams;
|
use utoipa::IntoParams;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::dtos::grant_dto::{
|
use crate::application::dtos::grant_dto::{
|
||||||
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SubjectDto,
|
CreateGrantDto, GrantDto, PermissionDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||||
UpdateRoleDto,
|
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||||
};
|
};
|
||||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
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::common::errors::DomainError;
|
||||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
use crate::domain::errors::ErrorKind;
|
||||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
use crate::domain::services::authorization::{
|
||||||
|
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||||
|
};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
use crate::interfaces::middleware::auth::AuthUser;
|
||||||
|
|
||||||
|
type AppStateRef = Arc<AppState>;
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// POST /api/grants
|
// POST /api/grants
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -44,10 +53,11 @@ use crate::interfaces::middleware::auth::AuthUser;
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn create_grant(
|
pub async fn create_grant(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Json(dto): Json<CreateGrantDto>,
|
Json(dto): Json<CreateGrantDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
|
|
||||||
// Validate: exactly one of permissions/role
|
// Validate: exactly one of permissions/role
|
||||||
@@ -118,10 +128,11 @@ pub async fn create_grant(
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn revoke_grant(
|
pub async fn revoke_grant(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let grant_id = match Uuid::parse_str(&id) {
|
let grant_id = match Uuid::parse_str(&id) {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
@@ -129,7 +140,7 @@ pub async fn revoke_grant(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Look up the grant to find the underlying resource (and granter).
|
// 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(Some((res, granter))) => (res, granter),
|
||||||
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
|
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
|
||||||
Err(e) => return AppError::from(e).into_response(),
|
Err(e) => return AppError::from(e).into_response(),
|
||||||
@@ -151,16 +162,6 @@ pub async fn revoke_grant(
|
|||||||
StatusCode::NO_CONTENT.into_response()
|
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
|
// PUT /api/grants/role
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -176,10 +177,11 @@ async fn find_grant_resource(
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn set_role(
|
pub async fn set_role(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Json(dto): Json<UpdateRoleDto>,
|
Json(dto): Json<UpdateRoleDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let subject: Subject = dto.subject.into();
|
let subject: Subject = dto.subject.into();
|
||||||
let resource: Resource = dto.resource.into();
|
let resource: Resource = dto.resource.into();
|
||||||
@@ -262,12 +264,13 @@ pub struct IncomingQuery {
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn list_incoming(
|
pub async fn list_incoming(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Query(q): Query<IncomingQuery>,
|
Query(q): Query<IncomingQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
match authz
|
match state
|
||||||
|
.authorization
|
||||||
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
|
||||||
.await
|
.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
|
// GET /api/grants/outgoing
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -292,11 +459,11 @@ pub async fn list_incoming(
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn list_outgoing(
|
pub async fn list_outgoing(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let caller_id = auth_user.id;
|
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) => {
|
Ok(grants) => {
|
||||||
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
|
||||||
(StatusCode::OK, Json(dtos)).into_response()
|
(StatusCode::OK, Json(dtos)).into_response()
|
||||||
@@ -327,10 +494,11 @@ pub struct OnResourceQuery {
|
|||||||
tag = "grants"
|
tag = "grants"
|
||||||
)]
|
)]
|
||||||
pub async fn list_on_resource(
|
pub async fn list_on_resource(
|
||||||
State(authz): State<Arc<PgAclEngine>>,
|
State(state): State<AppStateRef>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Query(q): Query<OnResourceQuery>,
|
Query(q): Query<OnResourceQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let authz = &state.authorization;
|
||||||
let caller_id = auth_user.id;
|
let caller_id = auth_user.id;
|
||||||
let resource: Resource = ResourceDto {
|
let resource: Resource = ResourceDto {
|
||||||
kind: q.resource_type,
|
kind: q.resource_type,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ use crate::application::dtos::folder_dto::{
|
|||||||
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto,
|
||||||
};
|
};
|
||||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
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::{
|
use crate::application::dtos::i18n_dto::{
|
||||||
LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto,
|
LocaleDto, TranslationErrorDto, TranslationRequestDto, TranslationResponseDto,
|
||||||
};
|
};
|
||||||
@@ -52,10 +56,6 @@ use crate::interfaces::api::handlers::contacts_handler::{
|
|||||||
use crate::interfaces::api::handlers::dedup_handler::{
|
use crate::interfaces::api::handlers::dedup_handler::{
|
||||||
DedupUploadResponse, HashCheckResponse, StatsResponse,
|
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;
|
use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
@@ -211,6 +211,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
handlers::grant_handler::revoke_grant,
|
handlers::grant_handler::revoke_grant,
|
||||||
handlers::grant_handler::set_role,
|
handlers::grant_handler::set_role,
|
||||||
handlers::grant_handler::list_incoming,
|
handlers::grant_handler::list_incoming,
|
||||||
|
handlers::grant_handler::list_shared_with_me,
|
||||||
handlers::grant_handler::list_outgoing,
|
handlers::grant_handler::list_outgoing,
|
||||||
handlers::grant_handler::list_on_resource,
|
handlers::grant_handler::list_on_resource,
|
||||||
),
|
),
|
||||||
@@ -296,6 +297,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
CreateGrantDto,
|
CreateGrantDto,
|
||||||
UpdateRoleDto,
|
UpdateRoleDto,
|
||||||
GrantDto,
|
GrantDto,
|
||||||
|
SharedWithMeDto,
|
||||||
|
SharedWithMeItemDto,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
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 share_service = app_state.share_service.clone();
|
||||||
let favorites_service = app_state.favorites_service.clone();
|
let favorites_service = app_state.favorites_service.clone();
|
||||||
let recent_service = app_state.recent_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
|
// Initialize the batch operations service
|
||||||
let mut batch_service_builder = BatchOperationService::default(
|
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()
|
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 = {
|
let grants_router = {
|
||||||
use crate::interfaces::api::handlers::grant_handler;
|
use crate::interfaces::api::handlers::grant_handler;
|
||||||
Router::new()
|
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("/{id}", delete(grant_handler::revoke_grant))
|
||||||
.route("/role", put(grant_handler::set_role))
|
.route("/role", put(grant_handler::set_role))
|
||||||
.route("/incoming", get(grant_handler::list_incoming))
|
.route("/incoming", get(grant_handler::list_incoming))
|
||||||
|
.route(
|
||||||
|
"/incoming/resources",
|
||||||
|
get(grant_handler::list_shared_with_me),
|
||||||
|
)
|
||||||
.route("/outgoing", get(grant_handler::list_outgoing))
|
.route("/outgoing", get(grant_handler::list_outgoing))
|
||||||
.with_state(authorization.clone())
|
.with_state(app_state.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a router without the i18n routes
|
// Create a router without the i18n routes
|
||||||
|
|||||||
Reference in New Issue
Block a user