feat(myshares): new version of myshares sections, supporting grants Users + Tokens
This commit is contained in:
@@ -20,8 +20,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto,
|
||||
SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto,
|
||||
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -31,7 +32,8 @@ use crate::common::di::AppState;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::errors::ErrorKind;
|
||||
use crate::domain::services::authorization::{
|
||||
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
|
||||
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
|
||||
Subject,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
@@ -98,7 +100,10 @@ pub async fn create_grant(
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
match authz.grant(caller_id, subject, perm, resource, expires_at).await {
|
||||
match authz
|
||||
.grant(caller_id, subject, perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(grant) => results.push(grant.into()),
|
||||
Err(err) => {
|
||||
error!("grant insert failed for {perm:?}: {err}");
|
||||
@@ -227,7 +232,10 @@ pub async fn set_role(
|
||||
}
|
||||
}
|
||||
for perm in &to_add {
|
||||
if let Err(e) = authz.grant(caller_id, subject, *perm, resource, expires_at).await {
|
||||
if let Err(e) = authz
|
||||
.grant(caller_id, subject, *perm, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
}
|
||||
@@ -236,7 +244,10 @@ pub async fn set_role(
|
||||
// includes newly added ones and any that were already present (retained).
|
||||
// Callers that omit expires_at will clear any existing expiry; this is
|
||||
// intentional: it keeps all permission rows for the pair consistent.
|
||||
if let Err(e) = authz.set_expiry_on_resource(subject, resource, expires_at).await {
|
||||
if let Err(e) = authz
|
||||
.set_expiry_on_resource(subject, resource, expires_at)
|
||||
.await
|
||||
{
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
@@ -560,6 +571,180 @@ pub async fn list_on_resource(
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/grants/outgoing/resources
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/grants/outgoing/resources",
|
||||
params(SharedWithMeQuery),
|
||||
responses(
|
||||
(status = 200,
|
||||
description = "Cursor-paginated resources the caller has shared with others. \
|
||||
Each item carries the full resource details plus all subjects \
|
||||
(users and tokens) the resource was shared with. \
|
||||
`next_cursor` is absent on the last page.",
|
||||
body = MySharesDto),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "grants"
|
||||
)]
|
||||
pub async fn list_my_shares(
|
||||
State(state): State<AppStateRef>,
|
||||
auth_user: AuthUser,
|
||||
Query(q): Query<SharedWithMeQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
let limit = q.limit_clamped() as u32;
|
||||
|
||||
let sort_by = q.sort_by.as_deref().unwrap_or("first_shared_at");
|
||||
if !matches!(
|
||||
sort_by,
|
||||
"first_shared_at" | "name" | "type" | "subject" | "role"
|
||||
) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "invalid sort_by; valid values: first_shared_at, name, type, subject, role"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let reverse = q.reverse;
|
||||
|
||||
let cursor = q
|
||||
.decode_cursor::<GrantCursor>()
|
||||
.filter(|c| c.sort_by == sort_by && c.reverse == reverse);
|
||||
|
||||
let (summaries, next_cursor) = match state
|
||||
.authorization
|
||||
.list_outgoing_resources_paged(caller_id, limit, cursor, sort_by, reverse)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
};
|
||||
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service_concrete;
|
||||
|
||||
// Split summaries by resource kind for parallel resolution.
|
||||
let file_summaries: Vec<&OutgoingResourceSummary> = summaries
|
||||
.iter()
|
||||
.filter(|s| matches!(s.resource_type, ResourceKind::File))
|
||||
.collect();
|
||||
let folder_summaries: Vec<&OutgoingResourceSummary> = summaries
|
||||
.iter()
|
||||
.filter(|s| matches!(s.resource_type, ResourceKind::Folder))
|
||||
.collect();
|
||||
|
||||
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();
|
||||
|
||||
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)))
|
||||
);
|
||||
|
||||
let mut file_idx = 0usize;
|
||||
let mut folder_idx = 0usize;
|
||||
let mut items: Vec<OutgoingResourceItemDto> = Vec::with_capacity(summaries.len());
|
||||
|
||||
for summary in &summaries {
|
||||
let grants: Vec<OutgoingResourceGrantDto> = summary
|
||||
.grants
|
||||
.iter()
|
||||
.map(|g| OutgoingResourceGrantDto {
|
||||
grant_id: g.grant_id,
|
||||
subject_type: g.subject_type.clone(),
|
||||
subject_id: g.subject_id,
|
||||
subject_display: g.subject_display.clone(),
|
||||
role: role_from_permissions(&g.permissions).to_owned(),
|
||||
granted_at: g.granted_at,
|
||||
expires_at: g.expires_at,
|
||||
has_password: g.has_password,
|
||||
})
|
||||
.collect();
|
||||
|
||||
match summary.resource_type {
|
||||
ResourceKind::File => {
|
||||
let result = &file_results[file_idx];
|
||||
file_idx += 1;
|
||||
match result {
|
||||
Ok(file_dto) => {
|
||||
items.push(OutgoingResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
first_shared_at: summary.first_shared_at,
|
||||
resource: ResourceContentDto::File(
|
||||
file_dto.clone().without_hierarchy_info(),
|
||||
),
|
||||
grants,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
"Skipping stale outgoing 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(OutgoingResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
first_shared_at: summary.first_shared_at,
|
||||
resource: ResourceContentDto::Folder(
|
||||
folder_dto.clone().without_hierarchy_info(),
|
||||
),
|
||||
grants,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
warn!(
|
||||
"Skipping stale outgoing 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(MySharesDto::with_cursor(
|
||||
items,
|
||||
next_cursor.map(|c| c.encode()),
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// Silence unused-import warnings for SubjectDto when only certain endpoints
|
||||
// touch it directly.
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -325,6 +325,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
get(grant_handler::list_shared_with_me),
|
||||
)
|
||||
.route("/outgoing", get(grant_handler::list_outgoing))
|
||||
.route("/outgoing/resources", get(grant_handler::list_my_shares))
|
||||
.with_state(app_state.clone())
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user