2026-05-20 22:56:00 +02:00
//! REST handlers for the ReBAC grant management endpoints.
//!
//! All endpoints under `/api/grants`. The authenticated caller is taken from
//! the `AuthUser` extractor. Authorization for sharing operations is enforced
//! via `authz.require(caller, Share, resource)` — handlers never embed their
//! own checks (see CLAUDE.md § Authorization).
use axum ::{
Json ,
extract ::{ Path , Query , State },
http ::StatusCode ,
response ::IntoResponse ,
};
2026-05-24 01:05:13 +02:00
use futures ::future ::join_all ;
2026-05-20 22:56:00 +02:00
use serde ::Deserialize ;
use std ::sync ::Arc ;
2026-06-17 23:14:25 +02:00
use tracing ::{ error , warn };
2026-05-20 22:56:00 +02:00
use utoipa ::IntoParams ;
use uuid ::Uuid ;
2026-05-26 17:52:28 +02:00
use crate ::application ::dtos ::cursor ::PageCursor ;
2026-05-20 22:56:00 +02:00
use crate ::application ::dtos ::grant_dto ::{
2026-06-05 09:46:51 +02:00
CreateGrantDto , CreateGrantResponseDto , GrantDto , MySharesDto , NotifyOutcomeSetDto ,
2026-06-18 01:42:32 +02:00
OutgoingResourceGrantDto , OutgoingResourceItemDto , ResourceContentDto , ResourceDto ,
ResourceTypeDto , SharedWithMeDto , SharedWithMeItemDto , SharedWithMeQuery , SubjectDto ,
SubjectInputDto , UpdateRoleDto , role_from_permissions ,
2026-05-20 22:56:00 +02:00
};
use crate ::application ::ports ::authorization_ports ::AuthorizationEngine ;
2026-05-24 01:05:13 +02:00
use crate ::application ::ports ::file_ports ::FileRetrievalUseCase ;
use crate ::application ::ports ::folder_ports ::FolderUseCase ;
2026-06-05 09:46:51 +02:00
use crate ::application ::services ::recipient_notification_service ::NotifyTrigger ;
2026-05-24 01:05:13 +02:00
use crate ::common ::di ::AppState ;
#[allow(unused_imports)]
2026-05-20 22:56:00 +02:00
use crate ::common ::errors ::DomainError ;
2026-05-24 01:05:13 +02:00
use crate ::domain ::errors ::ErrorKind ;
use crate ::domain ::services ::authorization ::{
2026-05-29 02:16:15 +02:00
GrantCursor , IncomingGrantSummary , OutgoingResourceSummary , Permission , Resource , ResourceKind ,
2026-06-18 01:42:32 +02:00
Role , Subject ,
2026-05-24 01:05:13 +02:00
};
2026-05-20 22:56:00 +02:00
use crate ::interfaces ::errors ::AppError ;
use crate ::interfaces ::middleware ::auth ::AuthUser ;
2026-05-24 01:05:13 +02:00
type AppStateRef = Arc < AppState > ;
2026-05-20 22:56:00 +02:00
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants" ,
request_body = CreateGrantDto,
responses(
2026-06-05 09:46:51 +02:00
(status = 201, description = "Grant(s) created" , body = CreateGrantResponseDto),
2026-05-20 22:56:00 +02:00
(status = 400, description = "Invalid input (both/neither of permissions+role provided)" ),
(status = 404, description = "Resource not found OR caller lacks Share permission" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn create_grant (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Json ( dto ) : Json < CreateGrantDto > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
2026-06-18 01:42:32 +02:00
let role : Role = dto . role . into ();
2026-05-20 22:56:00 +02:00
let resource : Resource = dto . resource . into ();
2026-05-28 19:56:31 +02:00
let expires_at = dto . expires_at ;
2026-05-20 22:56:00 +02:00
// Caller must have Share on the resource (owners pass via short-circuit).
if let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
2026-06-02 10:47:37 +02:00
// Resolve the subject. For the email variant this lazily provisions
// an external user (or reuses an existing match) and remembers the
// resolved User so the invitation email can be sent after the grant
// rows land.
let ( subject , invite_recipient ) = match dto . subject {
SubjectInputDto ::User { id } => ( Subject ::User ( id ), None ),
SubjectInputDto ::Group { id } => ( Subject ::Group ( id ), None ),
SubjectInputDto ::Token { id } => ( Subject ::Token ( id ), None ),
SubjectInputDto ::Email { email } => {
let Some ( invite_svc ) = state . magic_link_invite_service . as_ref () else {
return AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Magic-link invitations are not configured on this server \
(set OXICLOUD_SMTP_HOST in .env to enable)" ,
"ServiceUnavailable" ,
)
. into_response ();
};
2026-06-02 14:23:31 +02:00
// PR 12 — per-sharer ceiling: 50 email-invitations / hour
// per caller. Hitting the cap returns 429 because the
// caller is authenticated and rate-limit visibility leaks
// nothing they don't already know about their own
// behaviour.
if state
. email_invite_rate_limiter
. check_and_increment ( & caller_id . to_string ())
. is_err ()
{
tracing ::warn! (
target : "audit" ,
event = "grants.email_invite" ,
reason = "rate_limited" ,
caller_id = % caller_id ,
"Per-sharer email-invite rate limit exceeded"
);
return crate ::interfaces ::middleware ::rate_limit ::too_many_requests (
state . email_invite_rate_limiter . retry_after (),
);
}
2026-06-03 14:26:45 +02:00
// PR C: pass the inviter id so resolve_or_create_recipient
// can inherit their preferred_locale onto a freshly-
// provisioned external user (best-effort; lookup failure
// just leaves the new row's locale NULL, no hard error).
match invite_svc
. resolve_or_create_recipient ( & email , Some ( caller_id ))
. await
{
2026-06-02 10:47:37 +02:00
Ok ( user ) => ( Subject ::User ( user . id ()), Some ( user )),
Err ( e ) => return AppError ::from ( e ). into_response (),
}
}
};
2026-06-18 01:42:32 +02:00
// Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in
// the engine makes repeated POSTs with the same (subject, resource)
// a role refresh, matching the PATCH-style semantics callers expect.
let grant = match authz
2026-06-17 23:14:25 +02:00
. set_role ( caller_id , subject , role , resource , expires_at )
. await
{
2026-06-18 01:42:32 +02:00
Ok ( g ) => g ,
Err ( err ) => {
error! ( "set_role write failed: {err}" );
return AppError ::from ( err ). into_response ();
}
};
let grants = vec! [ GrantDto ::from ( grant )];
2026-06-17 23:14:25 +02:00
tracing ::info! (
target : "audit" ,
event = "role_grant.created" ,
caller_id = % caller_id ,
subject_type = subject . type_str (),
subject_id = % subject . id (),
resource_type = resource . type_str (),
resource_id = % resource . id (),
role = role . as_str (),
expires_at = ? expires_at ,
"🤝 grant created with role '{}'" , role . as_str (),
2026-05-20 22:56:00 +02:00
);
2026-06-02 10:47:37 +02:00
2026-06-05 09:46:51 +02:00
// PR N1 — route the post-grant notification through the unified
// RecipientNotificationService. Handles user/group/token subjects
// uniformly (Token subjects return an empty outcome set); applies
// per-(granter, recipient) coalesce + per-recipient hard rate
// limit; dispatches the magic-link arm (delegating to
// MagicLinkInviteService::issue_invitation) for eligible externals
// and the plain-notification arm for internal users; honours the
// per-user `notify_on_share` opt-out and the operator-level
// `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE` flag. SMTP failures
// remain non-fatal — the grant rows are already in place and the
// service captures every per-recipient result as a NotifyOutcome
// rather than an Err.
//
// For the email-resolved subject variant we already loaded the
// recipient `User` above for the lazy-provision side effect; the
// notification service re-resolves the same id, which is cheap and
// keeps the entry-point signature uniform across subject types.
let _ = invite_recipient ; // value used only for its side effect above
// Load the granter as a full `User` entity — the notification
// service uses display fields (`username`, `given/family_name`) for
// the inviter label in the email body. Failure here means the JWT
// claims correspond to a user row that has since been deleted; we
// return the grants without a notification rather than rolling back.
let notification = match (
state . recipient_notification_service . as_ref (),
state . auth_service . as_ref (),
) {
( Some ( svc ), Some ( auth_svc )) => {
match auth_svc
. auth_application_service
. get_user_entity ( caller_id )
. await
{
Ok ( granter ) => match svc
. send_share_notification (
& granter ,
subject ,
resource ,
NotifyTrigger ::GrantCreated ,
)
. await
{
Ok ( set ) => set . to_dto (),
Err ( e ) => {
warn! (
"notification dispatch failed for grant action by {}: {}" ,
caller_id , e
);
NotifyOutcomeSetDto ::empty ()
}
},
Err ( e ) => {
warn! (
"granter {} user-row load failed; skipping notification: {}" ,
caller_id , e
);
NotifyOutcomeSetDto ::empty ()
}
}
2026-06-02 10:47:37 +02:00
}
2026-06-05 09:46:51 +02:00
_ => NotifyOutcomeSetDto ::empty (),
};
2026-06-02 10:47:37 +02:00
2026-06-05 09:46:51 +02:00
(
StatusCode ::CREATED ,
Json ( CreateGrantResponseDto {
2026-06-18 01:42:32 +02:00
grants ,
2026-06-05 09:46:51 +02:00
notification ,
}),
)
. into_response ()
2026-05-20 22:56:00 +02:00
}
// ════════════════════════════════════════════════════════════════════════════
// DELETE /api/grants/{id}
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
delete,
path = "/api/grants/{id}" ,
params(( "id" = String, Path, description = "Grant UUID" )),
responses(
(status = 204, description = "Grant revoked (or did not exist)" ),
(status = 404, description = "Caller lacks Share permission on the underlying resource" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn revoke_grant (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Path ( id ) : Path < String > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
let grant_id = match Uuid ::parse_str ( & id ) {
Ok ( u ) => u ,
Err ( _ ) => return AppError ::not_found ( format! ( "Grant {id} not found" )). into_response (),
};
2026-06-17 23:14:25 +02:00
// Look up the grant to find the subject, resource, and granter.
// `find_grant_full_by_id` returns the subject too — needed for the
// `clear_role` dual-write below (role_grants is keyed by (subject,
// resource), not by access_grants id).
let ( subject , resource , granter ) = match authz . find_grant_full_by_id ( grant_id ). await {
Ok ( Some ( triple )) => triple ,
2026-05-20 22:56:00 +02:00
Ok ( None ) => return StatusCode ::NO_CONTENT . into_response (), // idempotent
Err ( e ) => return AppError ::from ( e ). into_response (),
};
// Caller is authorized if they are the granter OR have Share on the resource.
2026-06-17 23:14:25 +02:00
if granter != caller_id
2026-05-20 22:56:00 +02:00
&& let Err ( e ) = authz
2026-06-17 23:14:25 +02:00
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
2026-05-20 22:56:00 +02:00
. await
{
return AppError ::from ( e ). into_response ();
}
if let Err ( e ) = authz . revoke ( grant_id ). await {
return AppError ::from ( e ). into_response ();
}
2026-06-17 23:14:25 +02:00
// D-Prep dual-write: clear the role_grants row for this (subject,
// resource). Idempotent — succeeds whether or not the row existed.
//
// Today's API revokes one access_grants row by id; the role_grants
// row models the WHOLE (subject, resource) cluster. Calling clear_role
// here effectively revokes the WHOLE role assignment in role_grants,
// even if other per-permission access_grants rows remain. This is the
// correct semantics for the eventual cleanup-PR model (role_grants is
// role-keyed; once access_grants goes away, "revoke" means "drop the
// role"). During the dual-write window the two tables can drift
// briefly if a caller revokes only some permissions of a role, but
// the engine still reads access_grants so behaviour is unchanged.
if let Err ( e ) = authz . clear_role ( subject , resource ). await {
return AppError ::from ( e ). into_response ();
}
tracing ::info! (
target : "audit" ,
event = "role_grant.revoked" ,
caller_id = % caller_id ,
grant_id = % grant_id ,
subject_type = subject . type_str (),
subject_id = % subject . id (),
resource_type = resource . type_str (),
resource_id = % resource . id (),
granter_id = % granter ,
self_revoke = ( granter == caller_id ),
"🗑️ grant revoked" ,
);
2026-05-20 22:56:00 +02:00
StatusCode ::NO_CONTENT . into_response ()
}
2026-06-05 09:46:51 +02:00
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants/{id}/notify — manual share-notification resend
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants/{id}/notify" ,
params(( "id" = String, Path, description = "Grant UUID" )),
responses(
(status = 204, description = "Notification(s) dispatched" ),
(status = 200, description = "Mixed outcome (some recipients coalesced / not-applicable); body carries the full NotifyOutcomeSet" , body = NotifyOutcomeSetDto),
(status = 404, description = "Grant not found OR caller is not the granter" ),
(status = 409, description = "Token subject (use the existing /magic/v1/{token}/resend channel)" ),
(status = 429, description = "Per-recipient hard rate limit exceeded" ),
),
security(( "bearerAuth" = [])),
tag = "grants"
)]
pub async fn notify_grant_recipient (
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 ,
Err ( _ ) => return AppError ::not_found ( format! ( "Grant {id} not found" )). into_response (),
};
// Load the grant. Anti-enumeration: missing AND not-owner both
// surface as 404 to the caller; only the audit row carries the
// real reason. Mirrors `revoke_grant`'s precedent.
let ( subject , resource , granter_id ) = match authz . find_grant_full_by_id ( grant_id ). await {
Ok ( Some ( t )) => t ,
Ok ( None ) => {
tracing ::info! (
target : "audit" ,
event = "grant.notify_skipped" ,
reason = "grant_not_found" ,
caller_id = % caller_id ,
grant_id = % grant_id ,
"🤫 manual notify rejected: grant {} not found" ,
grant_id ,
);
return AppError ::not_found ( format! ( "Grant {grant_id} not found" )). into_response ();
}
Err ( e ) => return AppError ::from ( e ). into_response (),
};
if granter_id != caller_id {
tracing ::info! (
target : "audit" ,
event = "grant.notify_skipped" ,
reason = "not_owner" ,
caller_id = % caller_id ,
grant_id = % grant_id ,
actual_granter = % granter_id ,
"🤫 manual notify rejected: caller {} is not the granter of {}" ,
caller_id ,
grant_id ,
);
return AppError ::not_found ( format! ( "Grant {grant_id} not found" )). into_response ();
}
// Token subjects can't be notified — the link share has no human
// recipient to email. Map to 409 so the frontend can hide the menu
// item for these as defense-in-depth (the v1 UI already does this
// client-side; this is the server-side enforcement).
if matches! ( subject , Subject ::Token ( _ )) {
return AppError ::new (
StatusCode ::CONFLICT ,
"Cannot notify a link-share recipient — token shares have no email channel" ,
"subject_is_token" ,
)
. into_response ();
}
// Load the granter entity (we are the granter; needed for the
// notification email body's "Alice shared X with you" salutation).
let Some ( auth_svc ) = state . auth_service . as_ref () else {
return AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Authentication subsystem not available" ,
"ServiceUnavailable" ,
)
. into_response ();
};
let granter = match auth_svc
. auth_application_service
. get_user_entity ( caller_id )
. await
{
Ok ( u ) => u ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
let Some ( svc ) = state . recipient_notification_service . as_ref () else {
return AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Notification service is not configured on this server \
(set OXICLOUD_SMTP_HOST in .env to enable)" ,
"ServiceUnavailable" ,
)
. into_response ();
};
let outcome_set = match svc
. send_share_notification ( & granter , subject , resource , NotifyTrigger ::ManualResend )
. await
{
Ok ( s ) => s ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
let dto = outcome_set . to_dto ();
// HTTP mapping per the plan:
// - empty outcomes (Token subject — already 409'd above; defense
// in depth) → 409
// - every outcome is Sent → 204 No Content
// - all RateLimited (no Sent) → 429 with the longest Retry-After
// - mixed → 200 with the full body
if dto . outcomes . is_empty () {
return AppError ::new (
StatusCode ::CONFLICT ,
"Grant has no notifiable recipients" ,
"subject_is_token" ,
)
. into_response ();
}
let any_sent = dto . outcomes . iter (). any ( | o | {
matches! (
o ,
crate ::application ::dtos ::grant_dto ::NotifyOutcomeDto ::Sent { .. }
)
});
let max_retry_after = dto
. outcomes
. iter ()
. filter_map ( | o | match o {
crate ::application ::dtos ::grant_dto ::NotifyOutcomeDto ::RateLimited {
retry_after_secs ,
} => Some ( * retry_after_secs ),
_ => None ,
})
. max ();
let all_sent = dto . outcomes . iter (). all ( | o | {
matches! (
o ,
crate ::application ::dtos ::grant_dto ::NotifyOutcomeDto ::Sent { .. }
)
});
if all_sent {
return StatusCode ::NO_CONTENT . into_response ();
}
if ! any_sent && let Some ( secs ) = max_retry_after {
return crate ::interfaces ::middleware ::rate_limit ::too_many_requests ( secs as u64 );
}
( StatusCode ::OK , Json ( dto )). into_response ()
}
2026-05-20 22:56:00 +02:00
// ════════════════════════════════════════════════════════════════════════════
// PUT /api/grants/role
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
put,
path = "/api/grants/role" ,
request_body = UpdateRoleDto,
responses(
(status = 200, description = "Role applied; returns the new full grant set" , body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn set_role (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Json ( dto ) : Json < UpdateRoleDto > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
let subject : Subject = dto . subject . into ();
let resource : Resource = dto . resource . into ();
2026-06-18 01:42:32 +02:00
let role : Role = dto . role . into ();
2026-05-28 19:56:31 +02:00
let expires_at = dto . expires_at ;
2026-05-20 22:56:00 +02:00
// Caller must have Share on the resource.
if let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
2026-06-18 01:42:32 +02:00
// Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT
// UPDATE in `set_role` turns this into a single UPSERT — no diff,
// no race window. Returns the resulting role row.
let grant = match authz
. set_role ( caller_id , subject , role , resource , expires_at )
2026-06-17 23:14:25 +02:00
. await
{
2026-05-20 22:56:00 +02:00
Ok ( g ) => g ,
Err ( e ) => return AppError ::from ( e ). into_response (),
};
2026-06-17 23:14:25 +02:00
tracing ::info! (
target : "audit" ,
event = "role_grant.role_set" ,
caller_id = % caller_id ,
subject_type = subject . type_str (),
subject_id = % subject . id (),
resource_type = resource . type_str (),
resource_id = % resource . id (),
2026-06-18 01:42:32 +02:00
role = role . as_str (),
2026-06-17 23:14:25 +02:00
expires_at = ? expires_at ,
2026-06-18 01:42:32 +02:00
"🔁 role set to '{}'" , role . as_str (),
2026-05-20 22:56:00 +02:00
);
2026-06-18 01:42:32 +02:00
( StatusCode ::OK , Json ( vec! [ GrantDto ::from ( grant )])). into_response ()
2026-05-20 22:56:00 +02:00
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/incoming" ,
responses(
2026-06-18 01:42:32 +02:00
(status = 200, description = "Direct role grants targeting the caller" , body = Vec<GrantDto>),
2026-05-20 22:56:00 +02:00
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_incoming (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
) -> impl IntoResponse {
let caller_id = auth_user . id ;
2026-05-24 01:05:13 +02:00
match state
. authorization
2026-06-18 01:42:32 +02:00
. list_incoming_grants ( Subject ::User ( caller_id ))
2026-05-20 22:56:00 +02:00
. await
{
Ok ( grants ) => {
let dtos : Vec < GrantDto > = grants . into_iter (). map ( Into ::into ). collect ();
( StatusCode ::OK , Json ( dtos )). into_response ()
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
2026-05-24 01:05:13 +02:00
// ════════════════════════════════════════════════════════════════════════════
// 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),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-24 01:05:13 +02:00
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.
2026-05-26 17:52:28 +02:00
let limit = q . limit_clamped () as u32 ;
2026-05-24 01:05:13 +02:00
2026-05-27 00:32:10 +02:00
// Validate sort_by (defaults to "granted_at").
let sort_by = q . sort_by . as_deref (). unwrap_or ( "granted_at" );
2026-05-29 13:10:13 +02:00
if ! matches! ( sort_by , "granted_at" | "granted_by" | "name" | "type" ) {
2026-05-27 00:32:10 +02:00
return (
StatusCode ::BAD_REQUEST ,
2026-05-29 13:10:13 +02:00
Json ( serde_json ::json! ({ "error" : "invalid sort_by; valid values: granted_at, granted_by, name, type" })),
2026-05-27 00:32:10 +02:00
)
. into_response ();
}
2026-05-24 01:05:13 +02:00
2026-05-28 00:44:20 +02:00
let reverse = q . reverse ;
// Decode cursor — discard it when the sort dimension or direction changed
// to avoid keyset confusion across sort modes.
2026-05-27 00:32:10 +02:00
let cursor = q
. decode_cursor ::< GrantCursor > ()
2026-05-28 00:44:20 +02:00
. filter ( | c | c . sort_by == sort_by && c . reverse == reverse );
2026-05-24 01:05:13 +02:00
// Fetch paged summaries from the ACL engine.
let ( summaries , next_cursor ) = match state
. authorization
2026-05-28 00:44:20 +02:00
. list_incoming_resources_paged ( subject , & kinds , limit , cursor , sort_by , reverse )
2026-05-24 01:05:13 +02:00
. 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 = 0 usize ;
let mut folder_idx = 0 usize ;
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 ,
2026-05-26 17:52:28 +02:00
resource : ResourceContentDto ::File (
file_dto . clone (). without_hierarchy_info (),
),
2026-05-24 01:05:13 +02:00
});
}
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 ,
2026-05-26 17:52:28 +02:00
resource : ResourceContentDto ::Folder (
folder_dto . clone (). without_hierarchy_info (),
),
2026-05-24 01:05:13 +02:00
});
}
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 ,
2026-05-26 17:52:28 +02:00
Json ( SharedWithMeDto ::with_cursor (
2026-05-24 01:05:13 +02:00
items ,
2026-05-26 17:52:28 +02:00
next_cursor . map ( | c | c . encode ()),
)),
2026-05-24 01:05:13 +02:00
)
. into_response ()
}
2026-05-20 22:56:00 +02:00
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/outgoing
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
get,
path = "/api/grants/outgoing" ,
responses(
(status = 200, description = "Grants the caller has created" , body = Vec<GrantDto>),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_outgoing (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
) -> impl IntoResponse {
let caller_id = auth_user . id ;
2026-05-24 01:05:13 +02:00
match state . authorization . list_outgoing_grants ( caller_id ). await {
2026-05-20 22:56:00 +02:00
Ok ( grants ) => {
let dtos : Vec < GrantDto > = grants . into_iter (). map ( Into ::into ). collect ();
( StatusCode ::OK , Json ( dtos )). into_response ()
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants?resource_type=...&resource_id=...
// (list grants on a specific resource — requires Share on it)
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct OnResourceQuery {
pub resource_type : ResourceTypeDto ,
pub resource_id : Uuid ,
}
#[utoipa::path(
get,
path = "/api/grants" ,
params(OnResourceQuery),
responses(
(status = 200, description = "Grants on the specified resource" , body = Vec<GrantDto>),
(status = 404, description = "Resource not found or caller lacks Share" ),
),
2026-05-27 12:43:07 +02:00
security(( "bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_on_resource (
2026-05-24 01:05:13 +02:00
State ( state ) : State < AppStateRef > ,
2026-05-20 22:56:00 +02:00
auth_user : AuthUser ,
Query ( q ) : Query < OnResourceQuery > ,
) -> impl IntoResponse {
2026-05-24 01:05:13 +02:00
let authz = & state . authorization ;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user . id ;
let resource : Resource = ResourceDto {
kind : q . resource_type ,
id : q . resource_id ,
}
. into ();
if let Err ( e ) = authz
. require ( Subject ::User ( caller_id ), Permission ::Share , resource )
. await
{
return AppError ::from ( e ). into_response ();
}
match authz . list_grants_on_resource ( resource ). await {
Ok ( grants ) => {
let dtos : Vec < GrantDto > = grants . into_iter (). map ( Into ::into ). collect ();
( StatusCode ::OK , Json ( dtos )). into_response ()
}
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
2026-05-29 02:16:15 +02:00
// ════════════════════════════════════════════════════════════════════════════
// 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 = 0 usize ;
let mut folder_idx = 0 usize ;
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 ,
2026-06-05 09:46:51 +02:00
is_external : g . is_external ,
2026-05-29 02:16:15 +02:00
})
. collect ();
match summary . resource_type {
ResourceKind ::File => {
let result = & file_results [ file_idx ];
file_idx += 1 ;
match result {
Ok ( file_dto ) => {
2026-05-30 10:41:16 +02:00
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
2026-05-29 02:16:15 +02:00
items . push ( OutgoingResourceItemDto {
resource_type : ResourceTypeDto ::File ,
first_shared_at : summary . first_shared_at ,
2026-05-30 10:41:16 +02:00
resource : ResourceContentDto ::File ( file_dto . clone ()),
2026-05-29 02:16:15 +02:00
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 ,
2026-05-30 10:41:16 +02:00
resource : ResourceContentDto ::Folder ( folder_dto . clone ()),
2026-05-29 02:16:15 +02:00
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 ()
}
2026-05-20 22:56:00 +02:00
// Silence unused-import warnings for SubjectDto when only certain endpoints
// touch it directly.
#[allow(dead_code)]
fn _ensure_subject_dto_compiles ( _ : SubjectDto ) {}