//! 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}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; 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 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 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 for Resource { fn from(dto: ResourceDto) -> Self { match dto.kind { ResourceTypeDto::Folder => Resource::Folder(dto.id), ResourceTypeDto::File => Resource::File(dto.id), } } } impl From 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 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 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, //Commenter, Editor, //Manager, 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], /* reserved for future Role::Commenter => &[Permission::Read, Permission::Comment], */ Role::Editor => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, ], /* reserved for future Role::Manager => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, Permission::Share, ], */ 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>, #[serde(default)] pub role: Option, } /// `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, } impl From 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, } } } // ════════════════════════════════════════════════════════════════════════════ // Shared-with-me DTOs (GET /api/grants/incoming/resources) // ════════════════════════════════════════════════════════════════════════════ /// Query parameters for `GET /api/grants/incoming/resources`. /// /// `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. #[derive(Debug, Deserialize, IntoParams)] pub struct SharedWithMeQuery { /// Maximum number of items to return (1–200, default 50). #[serde(default = "CursorQuery::default_limit")] pub limit: u32, /// Opaque cursor from a previous response. Omit to start from the /// most-recently-granted item. pub cursor: Option, /// Sort dimension. Supported values: `"granted_at"` (default), /// `"granted_by"` (for swimlane grouping). pub sort_by: Option, /// Comma-separated resource types to include, e.g. `file,folder`. /// Omit to return all known types. pub resource_types: Option, /// 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, } 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(&self) -> Option { 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), } /// 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. #[derive(Debug, Serialize, ToSchema)] pub struct SharedWithMeItemDto { pub resource_type: ResourceTypeDto, /// All permissions the caller holds on this resource (aggregated). pub permissions: Vec, /// Earliest grant date for this resource. pub granted_at: chrono::DateTime, /// UUID of the user who created the (earliest) grant. pub granted_by: Uuid, /// Full resource details. Shape is determined by `resource_type`. pub resource: ResourceContentDto, } /// Response for `GET /api/grants/incoming/resources`. pub type SharedWithMeDto = CursorListResponse;