//! 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, } #[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), } } } 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), }; 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 // ════════════════════════════════════════════════════════════════════════════ /// Subject shape accepted by `POST /api/grants`. Internally-tagged enum /// so the existing `{type:"user", id:"..."}` payload keeps working /// alongside the new `{type:"email", email:"..."}` variant that feeds /// the invite-by-email flow. The response-side [`SubjectDto`] stays /// unchanged — externals resolve to `Subject::User(uuid)` with /// `is_external = TRUE` on the user row, never a distinct subject type. #[derive(Debug, Clone, Deserialize, ToSchema)] #[serde(tag = "type", rename_all = "lowercase")] pub enum SubjectInputDto { User { id: Uuid, }, Group { id: Uuid, }, Token { id: Uuid, }, /// Invite-by-email. Lazily provisions an external user with the /// normalised address as both username and email when no match /// exists; otherwise reuses the existing user. Triggers a magic-link /// invitation email when the resolved user has no other login /// credential. Email { email: String, }, } /// `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: SubjectInputDto, pub resource: ResourceDto, #[serde(default)] pub permissions: Option>, #[serde(default)] pub role: Option, /// Optional expiry for every grant in this request. RFC 3339 / ISO 8601. #[serde(default)] pub expires_at: 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, /// Optional expiry applied to every grant written or updated by this call. #[serde(default)] pub expires_at: Option>, } // ════════════════════════════════════════════════════════════════════════════ // 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, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, } 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, expires_at: g.expires_at, } } } // ════════════════════════════════════════════════════════════════════════════ // Notification DTOs (PR N1) — surfaced in the create-grant and /notify // responses so the frontend can show actionable toasts ("Notified Carol", // "Carol already notified recently", "Notified 8 of 10 group members"). // ════════════════════════════════════════════════════════════════════════════ /// One per resolved recipient. `kind` discriminates; sibling fields are /// only meaningful for the matching variant. Tagged JSON shape: /// /// ```json /// { "kind": "sent", "detail": "magic_link" } /// { "kind": "sent", "detail": "plain_notification" } /// { "kind": "coalesced", "last_sent_at": "2026-06-04T12:00:00Z" } /// { "kind": "rate_limited", "retry_after_secs": 1800 } /// { "kind": "not_applicable", "reason": "recipient_opted_out" } /// ``` #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum NotifyOutcomeDto { /// An email actually went out for this recipient. `detail` is /// `"magic_link"` (external invitation with a fresh token) or /// `"plain_notification"` (internal "you got a new grant" mail). Sent { detail: String }, /// Skipped silently because this (granter, recipient) pair was /// notified less than the coalesce-window ago. The grant is still /// recorded; the recipient sees it next time they log in. Coalesced { last_sent_at: chrono::DateTime, }, /// Per-recipient hard cap (5/h) reached. The caller may retry after /// `retry_after_secs`. RateLimited { retry_after_secs: u32 }, /// No mail was dispatched for this recipient. `reason` is one of: /// - `"recipient_opted_out"` — user toggled `notify_on_share = false` /// - `"operator_disabled"` — `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=false` /// - `"no_email"` — user row has no email on file /// - `"oidc_only_no_email"` — OIDC-only user with no email claim /// - `"subject_is_token"` — anonymous link share (the surface /// that creates the grant or the `/notify` endpoint maps this to 409) NotApplicable { reason: String }, } /// The aggregated result of dispatching share notifications for ONE grant /// action (one `create_grant` request OR one `/notify` call). Carries /// per-recipient outcomes so the frontend can render a single /// summary-style toast: /// /// - `total_recipients = 1`, `outcomes[0] = Sent` → "Notified Carol" /// - `total_recipients = 1`, `outcomes[0] = Coalesced` → "Carol already /// notified recently" /// - `total_recipients = N`, all `Sent` → "Notified all N group members" /// - `total_recipients = N`, mix → "Notified 8 of 10 — 2 opted out" /// /// `total_recipients` equals `outcomes.len()` after resolution. For /// token-subject grants it is `0` (no human recipient — no toast). #[derive(Debug, Clone, Serialize, ToSchema)] pub struct NotifyOutcomeSetDto { pub total_recipients: usize, pub outcomes: Vec, } impl NotifyOutcomeSetDto { /// Construct an empty set (token subjects, no recipients to notify). pub fn empty() -> Self { Self { total_recipients: 0, outcomes: Vec::new(), } } /// Construct from a list of outcomes, deriving `total_recipients` /// from the list length. Use this from `RecipientNotificationService` /// after the per-member loop completes. pub fn from_outcomes(outcomes: Vec) -> Self { Self { total_recipients: outcomes.len(), outcomes, } } } /// Response body for `POST /api/grants`. Wraps the array of created /// grants (one per `permission` in the request) together with the /// aggregated notification result. Replaces the previous bare /// `Vec` shape; the frontend share modal is updated in /// lockstep. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct CreateGrantResponseDto { pub grants: Vec, pub notification: NotifyOutcomeSetDto, } // ════════════════════════════════════════════════════════════════════════════ // 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, } /// Derive the closest-matching role label from a set of permissions. /// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`. pub fn role_from_permissions(perms: &[Permission]) -> &'static str { if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) { "admin" } else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) { "editor" } else { "viewer" } } /// Response for `GET /api/grants/incoming/resources`. pub type SharedWithMeDto = CursorListResponse; // ════════════════════════════════════════════════════════════════════════════ // My-Shares DTOs (GET /api/grants/outgoing/resources) // ════════════════════════════════════════════════════════════════════════════ /// One (subject, permissions) entry within an outgoing resource item. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct OutgoingResourceGrantDto { pub grant_id: Uuid, /// `"user"` | `"token"` pub subject_type: String, pub subject_id: Uuid, /// Human-readable label (username for users, share name for tokens). pub subject_display: String, /// Derived role label: `"viewer"` | `"editor"` | `"admin"`. pub role: String, pub granted_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, /// Whether the token has a password set. Always `false` for user subjects. pub has_password: bool, /// True when the subject is a magic-link-only external user /// (PR N2). Always `false` for token and group subjects, and for /// internal users. Used by the My Shares per-row menu to choose /// between "Resend invitation email" (external) and "Notify by /// email" (internal). #[serde(default)] pub is_external: bool, } /// One item in the my-shares list. #[derive(Debug, Serialize, ToSchema)] pub struct OutgoingResourceItemDto { pub resource_type: ResourceTypeDto, /// Earliest grant date across all subjects on this resource. pub first_shared_at: chrono::DateTime, /// Full resource details. Shape is determined by `resource_type`. pub resource: ResourceContentDto, /// One entry per (subject, permissions) pair. pub grants: Vec, } /// Response for `GET /api/grants/outgoing/resources`. pub type MySharesDto = CursorListResponse;