Files
Oxicloud/src/interfaces/api/handlers/grant_handler.rs
T

1153 lines
45 KiB
Rust
Raw Normal View History

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,
};
use futures::future::join_all;
2026-05-20 22:56:00 +02:00
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, warn};
2026-05-20 22:56:00 +02:00
use utoipa::IntoParams;
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
2026-05-20 22:56:00 +02:00
use crate::application::dtos::grant_dto::{
CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto,
OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto,
ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery,
SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions,
2026-05-20 22:56:00 +02:00
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::services::recipient_notification_service::NotifyTrigger;
use crate::common::di::AppState;
#[allow(unused_imports)]
2026-05-20 22:56:00 +02:00
use crate::common::errors::DomainError;
use crate::domain::errors::ErrorKind;
use crate::domain::services::authorization::{
GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind,
Subject,
};
2026-05-20 22:56:00 +02:00
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
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(
(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"),
),
security(("bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn create_grant(
State(state): State<AppStateRef>,
2026-05-20 22:56:00 +02:00
auth_user: AuthUser,
Json(dto): Json<CreateGrantDto>,
) -> impl IntoResponse {
let authz = &state.authorization;
2026-05-20 22:56:00 +02:00
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role. Capture BOTH the
// permission list (for the per-permission access_grants writes that
// keep the old engine read path working) AND the role (for the new
// role_grants `set_role` dual-write that lands after the per-
// permission loop).
let (permissions, role): (Vec<Permission>, Role) = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => {
let perms: Vec<Permission> = perms.into_iter().map(Into::into).collect();
// Derive the closest matching role from the raw permission set
// so we have ONE role to mirror into role_grants. `Role::parse`
// always succeeds here because `role_from_permissions` only
// emits known role strings.
let role = Role::parse(role_from_permissions(&perms))
.expect("role_from_permissions returns a known role string");
(perms, role)
}
(None, Some(role)) => (role.expand().to_vec(), role),
2026-05-20 22:56:00 +02:00
(Some(_), Some(_)) => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Provide either 'permissions' or 'role', not both",
"InvalidInput",
)
.into_response();
}
_ => {
return AppError::new(
StatusCode::BAD_REQUEST,
"Either 'permissions' (non-empty) or 'role' is required",
"InvalidInput",
)
.into_response();
}
};
let resource: Resource = dto.resource.into();
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();
}
// 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();
};
// 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(),
);
}
// 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
{
Ok(user) => (Subject::User(user.id()), Some(user)),
Err(e) => return AppError::from(e).into_response(),
}
}
};
2026-05-20 22:56:00 +02:00
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
// `storage.access_grants.permission` CHECK constraint predates
// `Permission::Manage`; it accepts only the original 6 values.
// Manage exists in the Owner bundle for engine read-path use
// (via `roles_implying`) and gets persisted via the role_grants
// dual-write below. Skipping it here keeps the access_grants
// safety net populated without tripping the CHECK; the cleanup
// PR that drops access_grants also drops this skip.
if perm == Permission::Manage {
continue;
}
match authz
.grant(caller_id, subject, perm, resource, expires_at)
.await
{
2026-05-20 22:56:00 +02:00
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
}
}
// D-Prep dual-write: mirror the role assignment into storage.role_grants.
// ON CONFLICT UPDATE makes this idempotent — repeated POSTs with the
// same (subject, resource) update the role in place, matching the
// PATCH-style semantics callers will get after the engine read pivot.
if let Err(err) = authz
.set_role(caller_id, subject, role, resource, expires_at)
.await
{
error!("set_role dual-write failed: {err}");
return AppError::from(err).into_response();
}
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(),
permission_count = results.len(),
expires_at = ?expires_at,
"🤝 grant created with role '{}'", role.as_str(),
2026-05-20 22:56:00 +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()
}
}
}
_ => NotifyOutcomeSetDto::empty(),
};
(
StatusCode::CREATED,
Json(CreateGrantResponseDto {
grants: results,
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"),
),
security(("bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn revoke_grant(
State(state): State<AppStateRef>,
2026-05-20 22:56:00 +02:00
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
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(),
};
// 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.
if granter != caller_id
2026-05-20 22:56:00 +02:00
&& let Err(e) = authz
.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();
}
// 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()
}
// ════════════════════════════════════════════════════════════════════════════
// 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"),
),
security(("bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn set_role(
State(state): State<AppStateRef>,
2026-05-20 22:56:00 +02:00
auth_user: AuthUser,
Json(dto): Json<UpdateRoleDto>,
) -> impl IntoResponse {
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();
let expires_at = dto.expires_at;
2026-05-20 22:56:00 +02:00
let target_perms: std::collections::HashSet<Permission> =
dto.role.expand().iter().copied().collect();
// 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();
}
// Fetch current grants on the resource for this subject.
let current = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let current_perms: std::collections::HashSet<Permission> = current
.iter()
.filter(|g| g.subject == subject)
.map(|g| g.permission)
.collect();
// Diff and apply. `Permission::Manage` is excluded from both sides
// because the historical `access_grants.permission` CHECK doesn't
// accept it — see the matching skip in `create_grant`. The role
// assignment captures Manage via the role_grants `set_role` call
// further down; the engine's read-path uses `roles_implying(Manage)`
// → `[Owner]` and never goes through per-permission rows.
let to_add: Vec<Permission> = target_perms
.difference(&current_perms)
.copied()
.filter(|p| *p != Permission::Manage)
.collect();
let to_remove: Vec<Permission> = current_perms
.difference(&target_perms)
.copied()
.filter(|p| *p != Permission::Manage)
.collect();
2026-05-20 22:56:00 +02:00
for perm in &to_remove {
if let Some(g) = current
.iter()
.find(|g| g.subject == subject && g.permission == *perm)
&& let Err(e) = authz.revoke(g.id).await
{
return AppError::from(e).into_response();
}
}
for perm in &to_add {
if let Err(e) = authz
.grant(caller_id, subject, *perm, resource, expires_at)
.await
{
2026-05-20 22:56:00 +02:00
return AppError::from(e).into_response();
}
}
// Sync expiry on all remaining grants for this (subject, resource) pair —
// 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
{
return AppError::from(e).into_response();
}
// D-Prep dual-write: mirror the resulting role into storage.role_grants.
// The per-permission diff above keeps access_grants converged; this
// single UPSERT keeps role_grants in sync with the OVERALL outcome
// (one row carrying the role + expiry). After the engine read pivot
// and the access_grants drop, the per-permission diff above goes
// away and this call becomes the only mutation the handler performs.
if let Err(e) = authz
.set_role(caller_id, subject, dto.role, resource, expires_at)
.await
{
return AppError::from(e).into_response();
}
2026-05-20 22:56:00 +02:00
// Return the new full set.
let after = match authz.list_grants_on_resource(resource).await {
Ok(g) => g,
Err(e) => return AppError::from(e).into_response(),
};
let mine: Vec<GrantDto> = after
.into_iter()
.filter(|g| g.subject == subject)
.map(Into::into)
.collect();
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(),
role = dto.role.as_str(),
permissions_added = to_add.len(),
permissions_removed = to_remove.len(),
expires_at = ?expires_at,
"🔁 role set to '{}' (+{} -{})",
dto.role.as_str(),
to_add.len(),
to_remove.len(),
2026-05-20 22:56:00 +02:00
);
(StatusCode::OK, Json(mine)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// GET /api/grants/incoming
// ════════════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize, IntoParams)]
pub struct IncomingQuery {
#[serde(default)]
pub permission: Option<PermissionDto>,
}
#[utoipa::path(
get,
path = "/api/grants/incoming",
params(IncomingQuery),
responses(
(status = 200, description = "Direct grants targeting the caller", body = Vec<GrantDto>),
),
security(("bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_incoming(
State(state): State<AppStateRef>,
2026-05-20 22:56:00 +02:00
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match state
.authorization
2026-05-20 22:56:00 +02:00
.list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into))
.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(),
}
}
// ════════════════════════════════════════════════════════════════════════════
// 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),
),
security(("bearerAuth" = [])),
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_clamped() as u32;
// Validate sort_by (defaults to "granted_at").
let sort_by = q.sort_by.as_deref().unwrap_or("granted_at");
if !matches!(sort_by, "granted_at" | "granted_by" | "name" | "type") {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type"})),
)
.into_response();
}
let reverse = q.reverse;
// Decode cursor — discard it when the sort dimension or direction changed
// to avoid keyset confusion across sort modes.
let cursor = q
.decode_cursor::<GrantCursor>()
.filter(|c| c.sort_by == sort_by && c.reverse == reverse);
// Fetch paged summaries from the ACL engine.
let (summaries, next_cursor) = match state
.authorization
.list_incoming_resources_paged(subject, &kinds, limit, cursor, sort_by, reverse)
.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,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
});
}
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,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
});
}
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::with_cursor(
items,
next_cursor.map(|c| c.encode()),
)),
)
.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>),
),
security(("bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_outgoing(
State(state): State<AppStateRef>,
2026-05-20 22:56:00 +02:00
auth_user: AuthUser,
) -> impl IntoResponse {
let caller_id = auth_user.id;
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"),
),
security(("bearerAuth" = [])),
2026-05-20 22:56:00 +02:00
tag = "grants"
)]
pub async fn list_on_resource(
State(state): State<AppStateRef>,
2026-05-20 22:56:00 +02:00
auth_user: AuthUser,
Query(q): Query<OnResourceQuery>,
) -> impl IntoResponse {
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(),
}
}
// ════════════════════════════════════════════════════════════════════════════
// 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,
is_external: g.is_external,
})
.collect();
match summary.resource_type {
ResourceKind::File => {
let result = &file_results[file_idx];
file_idx += 1;
match result {
Ok(file_dto) => {
// 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).
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
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()),
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) {}