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

557 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info, warn};
use utoipa::IntoParams;
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,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::common::di::AppState;
#[allow(unused_imports)]
use crate::common::errors::DomainError;
use crate::domain::errors::ErrorKind;
use crate::domain::services::authorization::{
GrantCursor, IncomingGrantSummary, Permission, Resource, ResourceKind, Subject,
};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
type AppStateRef = Arc<AppState>;
// ════════════════════════════════════════════════════════════════════════════
// POST /api/grants
// ════════════════════════════════════════════════════════════════════════════
#[utoipa::path(
post,
path = "/api/grants",
request_body = CreateGrantDto,
responses(
(status = 201, description = "Grant(s) created", body = Vec<GrantDto>),
(status = 400, description = "Invalid input (both/neither of permissions+role provided)"),
(status = 404, description = "Resource not found OR caller lacks Share permission"),
),
security(("bearerAuth" = [])),
tag = "grants"
)]
pub async fn create_grant(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Json(dto): Json<CreateGrantDto>,
) -> impl IntoResponse {
let authz = &state.authorization;
let caller_id = auth_user.id;
// Validate: exactly one of permissions/role
let permissions: Vec<Permission> = match (dto.permissions, dto.role) {
(Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(),
(None, Some(role)) => role.expand().to_vec(),
(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 subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
// 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();
}
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
for perm in permissions {
match authz.grant(caller_id, subject, perm, resource).await {
Ok(grant) => results.push(grant.into()),
Err(err) => {
error!("grant insert failed for {perm:?}: {err}");
return AppError::from(err).into_response();
}
}
}
info!(
"Created {} grant(s) for subject={:?} on resource={:?} by user {}",
results.len(),
subject,
resource,
caller_id
);
(StatusCode::CREATED, Json(results)).into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// 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" = [])),
tag = "grants"
)]
pub async fn revoke_grant(
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(),
};
// Look up the grant to find the underlying resource (and granter).
let on_resource = match authz.find_grant_by_id(grant_id).await {
Ok(Some((res, granter))) => (res, granter),
Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent
Err(e) => return AppError::from(e).into_response(),
};
// Caller is authorized if they are the granter OR have Share on the resource.
if on_resource.1 != caller_id
&& let Err(e) = authz
.require(Subject::User(caller_id), Permission::Share, on_resource.0)
.await
{
return AppError::from(e).into_response();
}
if let Err(e) = authz.revoke(grant_id).await {
return AppError::from(e).into_response();
}
info!("Revoked grant {grant_id} (caller {caller_id})");
StatusCode::NO_CONTENT.into_response()
}
// ════════════════════════════════════════════════════════════════════════════
// 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" = [])),
tag = "grants"
)]
pub async fn set_role(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Json(dto): Json<UpdateRoleDto>,
) -> impl IntoResponse {
let authz = &state.authorization;
let caller_id = auth_user.id;
let subject: Subject = dto.subject.into();
let resource: Resource = dto.resource.into();
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.
let to_add: Vec<Permission> = target_perms.difference(&current_perms).copied().collect();
let to_remove: Vec<Permission> = current_perms.difference(&target_perms).copied().collect();
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).await {
return AppError::from(e).into_response();
}
}
// 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();
info!(
"Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}",
caller_id, subject, resource, to_add, to_remove
);
(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" = [])),
tag = "grants"
)]
pub async fn list_incoming(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<IncomingQuery>,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match state
.authorization
.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" | "size"
) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid sort_by; valid values: granted_at, granted_by, name, type, size"})),
)
.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()
}
// ════════════════════════════════════════════════════════════════════════════
// 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" = [])),
tag = "grants"
)]
pub async fn list_outgoing(
State(state): State<AppStateRef>,
auth_user: AuthUser,
) -> impl IntoResponse {
let caller_id = auth_user.id;
match state.authorization.list_outgoing_grants(caller_id).await {
Ok(grants) => {
let dtos: Vec<GrantDto> = grants.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
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" = [])),
tag = "grants"
)]
pub async fn list_on_resource(
State(state): State<AppStateRef>,
auth_user: AuthUser,
Query(q): Query<OnResourceQuery>,
) -> impl IntoResponse {
let authz = &state.authorization;
let caller_id = auth_user.id;
let resource: Resource = ResourceDto {
kind: q.resource_type,
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(),
}
}
// Silence unused-import warnings for SubjectDto when only certain endpoints
// touch it directly.
#[allow(dead_code)]
fn _ensure_subject_dto_compiles(_: SubjectDto) {}