2026-05-20 22:56:00 +02:00
|
|
|
|
//! DTOs for the ReBAC `/api/grants` REST endpoints.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! The wire shapes are intentionally separate from the domain types
|
|
|
|
|
|
//! (`Subject`, `Resource`, `Permission`, `Grant`) so that domain stays
|
|
|
|
|
|
//! storage-agnostic and DTOs can evolve with the HTTP contract.
|
|
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-24 01:05:13 +02:00
|
|
|
|
use utoipa::{IntoParams, ToSchema};
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
2026-05-26 17:52:28 +02:00
|
|
|
|
use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor};
|
2026-05-24 01:05:13 +02:00
|
|
|
|
use crate::application::dtos::file_dto::FileDto;
|
|
|
|
|
|
use crate::application::dtos::folder_dto::FolderDto;
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use crate::domain::services::authorization::{Grant, Permission, Resource, Subject};
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Subject / Resource / Permission DTOs
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum SubjectTypeDto {
|
|
|
|
|
|
User,
|
|
|
|
|
|
Group,
|
|
|
|
|
|
Token,
|
|
|
|
|
|
External,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct SubjectDto {
|
|
|
|
|
|
#[serde(rename = "type")]
|
|
|
|
|
|
pub kind: SubjectTypeDto,
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<SubjectDto> for Subject {
|
|
|
|
|
|
fn from(dto: SubjectDto) -> Self {
|
|
|
|
|
|
match dto.kind {
|
|
|
|
|
|
SubjectTypeDto::User => Subject::User(dto.id),
|
|
|
|
|
|
SubjectTypeDto::Group => Subject::Group(dto.id),
|
|
|
|
|
|
SubjectTypeDto::Token => Subject::Token(dto.id),
|
|
|
|
|
|
SubjectTypeDto::External => Subject::External(dto.id),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Subject> for SubjectDto {
|
|
|
|
|
|
fn from(s: Subject) -> Self {
|
|
|
|
|
|
let (kind, id) = match s {
|
|
|
|
|
|
Subject::User(id) => (SubjectTypeDto::User, id),
|
|
|
|
|
|
Subject::Group(id) => (SubjectTypeDto::Group, id),
|
|
|
|
|
|
Subject::Token(id) => (SubjectTypeDto::Token, id),
|
|
|
|
|
|
Subject::External(id) => (SubjectTypeDto::External, id),
|
|
|
|
|
|
};
|
|
|
|
|
|
SubjectDto { kind, id }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum ResourceTypeDto {
|
|
|
|
|
|
Folder,
|
|
|
|
|
|
File,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct ResourceDto {
|
|
|
|
|
|
#[serde(rename = "type")]
|
|
|
|
|
|
pub kind: ResourceTypeDto,
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<ResourceDto> for Resource {
|
|
|
|
|
|
fn from(dto: ResourceDto) -> Self {
|
|
|
|
|
|
match dto.kind {
|
|
|
|
|
|
ResourceTypeDto::Folder => Resource::Folder(dto.id),
|
|
|
|
|
|
ResourceTypeDto::File => Resource::File(dto.id),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Resource> for ResourceDto {
|
|
|
|
|
|
fn from(r: Resource) -> Self {
|
|
|
|
|
|
let (kind, id) = match r {
|
|
|
|
|
|
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
|
|
|
|
|
|
Resource::File(id) => (ResourceTypeDto::File, id),
|
|
|
|
|
|
};
|
|
|
|
|
|
ResourceDto { kind, id }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum PermissionDto {
|
|
|
|
|
|
Read,
|
|
|
|
|
|
Create,
|
|
|
|
|
|
Share,
|
|
|
|
|
|
Comment,
|
|
|
|
|
|
Delete,
|
|
|
|
|
|
Update,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<PermissionDto> for Permission {
|
|
|
|
|
|
fn from(p: PermissionDto) -> Self {
|
|
|
|
|
|
match p {
|
|
|
|
|
|
PermissionDto::Read => Permission::Read,
|
|
|
|
|
|
PermissionDto::Create => Permission::Create,
|
|
|
|
|
|
PermissionDto::Share => Permission::Share,
|
|
|
|
|
|
PermissionDto::Comment => Permission::Comment,
|
|
|
|
|
|
PermissionDto::Delete => Permission::Delete,
|
|
|
|
|
|
PermissionDto::Update => Permission::Update,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Permission> for PermissionDto {
|
|
|
|
|
|
fn from(p: Permission) -> Self {
|
|
|
|
|
|
match p {
|
|
|
|
|
|
Permission::Read => PermissionDto::Read,
|
|
|
|
|
|
Permission::Create => PermissionDto::Create,
|
|
|
|
|
|
Permission::Share => PermissionDto::Share,
|
|
|
|
|
|
Permission::Comment => PermissionDto::Comment,
|
|
|
|
|
|
Permission::Delete => PermissionDto::Delete,
|
|
|
|
|
|
Permission::Update => PermissionDto::Update,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Roles (DTO-layer sugar)
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
|
|
|
|
|
|
#[serde(rename_all = "lowercase")]
|
|
|
|
|
|
pub enum Role {
|
|
|
|
|
|
Viewer,
|
2026-05-24 23:06:16 +02:00
|
|
|
|
//Commenter,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Editor,
|
2026-05-24 23:06:16 +02:00
|
|
|
|
//Manager,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Admin,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Role {
|
|
|
|
|
|
/// Expands a role into its constituent raw permissions. Storage and
|
|
|
|
|
|
/// engine know nothing about roles — the server normalizes here before
|
|
|
|
|
|
/// writing rows.
|
|
|
|
|
|
pub fn expand(self) -> &'static [Permission] {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
Role::Viewer => &[Permission::Read],
|
2026-05-24 23:06:16 +02:00
|
|
|
|
/* reserved for future
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Role::Commenter => &[Permission::Read, Permission::Comment],
|
2026-05-24 23:06:16 +02:00
|
|
|
|
*/
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Role::Editor => &[
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Permission::Comment,
|
|
|
|
|
|
Permission::Create,
|
|
|
|
|
|
Permission::Update,
|
|
|
|
|
|
],
|
2026-05-24 23:06:16 +02:00
|
|
|
|
/* reserved for future
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Role::Manager => &[
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Permission::Comment,
|
|
|
|
|
|
Permission::Create,
|
|
|
|
|
|
Permission::Update,
|
|
|
|
|
|
Permission::Share,
|
|
|
|
|
|
],
|
2026-05-24 23:06:16 +02:00
|
|
|
|
*/
|
2026-05-20 22:56:00 +02:00
|
|
|
|
Role::Admin => &[
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Permission::Comment,
|
|
|
|
|
|
Permission::Create,
|
|
|
|
|
|
Permission::Update,
|
|
|
|
|
|
Permission::Share,
|
|
|
|
|
|
Permission::Delete,
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Request DTOs
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
|
|
|
|
|
|
/// Server-side validation requires exactly one of the two to be present.
|
|
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct CreateGrantDto {
|
|
|
|
|
|
pub subject: SubjectDto,
|
|
|
|
|
|
pub resource: ResourceDto,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub permissions: Option<Vec<PermissionDto>>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub role: Option<Role>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `PUT /api/grants/role` — reconcile a subject's role on a resource.
|
|
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
|
|
|
|
pub struct UpdateRoleDto {
|
|
|
|
|
|
pub subject: SubjectDto,
|
|
|
|
|
|
pub resource: ResourceDto,
|
|
|
|
|
|
pub role: Role,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Response DTOs
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct GrantDto {
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
pub subject: SubjectDto,
|
|
|
|
|
|
pub resource: ResourceDto,
|
|
|
|
|
|
pub permission: PermissionDto,
|
|
|
|
|
|
pub granted_by: Uuid,
|
|
|
|
|
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl From<Grant> for GrantDto {
|
|
|
|
|
|
fn from(g: Grant) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
id: g.id,
|
|
|
|
|
|
subject: g.subject.into(),
|
|
|
|
|
|
resource: g.resource.into(),
|
|
|
|
|
|
permission: g.permission.into(),
|
|
|
|
|
|
granted_by: g.granted_by,
|
|
|
|
|
|
granted_at: g.granted_at,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-24 01:05:13 +02:00
|
|
|
|
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Shared-with-me DTOs (GET /api/grants/incoming/resources)
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
/// Query parameters for `GET /api/grants/incoming/resources`.
|
2026-05-26 17:52:28 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// `limit`, `cursor`, and `sort_by` follow the standard [`CursorQuery`]
|
|
|
|
|
|
/// contract. They are declared directly here rather than via
|
|
|
|
|
|
/// `#[serde(flatten)]` because `serde_urlencoded` (Axum's query extractor)
|
|
|
|
|
|
/// does not support flattening.
|
2026-05-24 01:05:13 +02:00
|
|
|
|
#[derive(Debug, Deserialize, IntoParams)]
|
|
|
|
|
|
pub struct SharedWithMeQuery {
|
|
|
|
|
|
/// Maximum number of items to return (1–200, default 50).
|
2026-05-26 17:52:28 +02:00
|
|
|
|
#[serde(default = "CursorQuery::default_limit")]
|
2026-05-24 01:05:13 +02:00
|
|
|
|
pub limit: u32,
|
2026-05-26 17:52:28 +02:00
|
|
|
|
/// Opaque cursor from a previous response. Omit to start from the
|
|
|
|
|
|
/// most-recently-granted item.
|
|
|
|
|
|
pub cursor: Option<String>,
|
|
|
|
|
|
/// Sort dimension. Supported values: `"granted_at"` (default),
|
|
|
|
|
|
/// `"granted_by"` (for swimlane grouping).
|
|
|
|
|
|
pub sort_by: Option<String>,
|
2026-05-24 01:05:13 +02:00
|
|
|
|
/// Comma-separated resource types to include, e.g. `file,folder`.
|
|
|
|
|
|
/// Omit to return all known types.
|
|
|
|
|
|
pub resource_types: Option<String>,
|
2026-05-28 00:44:20 +02:00
|
|
|
|
/// Reverse the sort order. Default `false` (normal order).
|
|
|
|
|
|
/// Must be the same on all pages of the same result set — the cursor
|
|
|
|
|
|
/// carries this flag so the server can validate consistency.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub reverse: bool,
|
2026-05-24 01:05:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:52:28 +02:00
|
|
|
|
impl SharedWithMeQuery {
|
|
|
|
|
|
/// Returns `limit` clamped to `[1, 200]`.
|
|
|
|
|
|
pub fn limit_clamped(&self) -> usize {
|
|
|
|
|
|
self.limit.clamp(1, 200) as usize
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Decode the optional cursor string. Invalid cursor → start from top.
|
|
|
|
|
|
pub fn decode_cursor<C: PageCursor>(&self) -> Option<C> {
|
|
|
|
|
|
self.cursor.as_deref().and_then(C::decode)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The resource payload for one item in the shared-with-me list.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The variant is discriminated by `resource_type` on the parent
|
|
|
|
|
|
/// [`SharedWithMeItemDto`]. Serialised as the inner object (no wrapper key)
|
|
|
|
|
|
/// via `#[serde(untagged)]`, so consumers see the file/folder fields directly
|
|
|
|
|
|
/// under the `resource` key.
|
|
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
|
|
|
|
#[serde(untagged)]
|
|
|
|
|
|
pub enum ResourceContentDto {
|
|
|
|
|
|
File(FileDto),
|
|
|
|
|
|
Folder(FolderDto),
|
2026-05-24 01:05:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 17:52:28 +02:00
|
|
|
|
/// One item in the shared-with-me list.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `resource_type` indicates whether `resource` contains a file or a folder.
|
|
|
|
|
|
/// Using a single `resource` field (instead of nullable `file`/`folder` pairs)
|
|
|
|
|
|
/// makes adding new resource types backward-compatible — only `resource_type`
|
|
|
|
|
|
/// gains a new variant; the wrapper shape stays the same.
|
2026-05-24 01:05:13 +02:00
|
|
|
|
#[derive(Debug, Serialize, ToSchema)]
|
|
|
|
|
|
pub struct SharedWithMeItemDto {
|
|
|
|
|
|
pub resource_type: ResourceTypeDto,
|
|
|
|
|
|
/// All permissions the caller holds on this resource (aggregated).
|
|
|
|
|
|
pub permissions: Vec<PermissionDto>,
|
|
|
|
|
|
/// Earliest grant date for this resource.
|
|
|
|
|
|
pub granted_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
|
/// UUID of the user who created the (earliest) grant.
|
|
|
|
|
|
pub granted_by: Uuid,
|
2026-05-26 17:52:28 +02:00
|
|
|
|
/// Full resource details. Shape is determined by `resource_type`.
|
|
|
|
|
|
pub resource: ResourceContentDto,
|
2026-05-24 01:05:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Response for `GET /api/grants/incoming/resources`.
|
2026-05-26 17:52:28 +02:00
|
|
|
|
pub type SharedWithMeDto = CursorListResponse<SharedWithMeItemDto>;
|