Files
Oxicloud/src/application/dtos/grant_dto.rs
T

490 lines
20 KiB
Rust
Raw Normal View History

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};
use utoipa::{IntoParams, ToSchema};
2026-05-20 22:56:00 +02:00
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;
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,
}
#[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),
}
}
}
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),
};
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,
//Commenter,
2026-05-20 22:56:00 +02:00
Editor,
//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],
/* reserved for future
2026-05-20 22:56:00 +02:00
Role::Commenter => &[Permission::Read, Permission::Comment],
*/
2026-05-20 22:56:00 +02:00
Role::Editor => &[
Permission::Read,
Permission::Comment,
Permission::Create,
Permission::Update,
],
/* 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-20 22:56:00 +02:00
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,
},
}
2026-05-20 22:56:00 +02:00
/// `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,
2026-05-20 22:56:00 +02:00
pub resource: ResourceDto,
#[serde(default)]
pub permissions: Option<Vec<PermissionDto>>,
#[serde(default)]
pub role: Option<Role>,
/// Optional expiry for every grant in this request. RFC 3339 / ISO 8601.
#[serde(default)]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
2026-05-20 22:56:00 +02:00
}
/// `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<chrono::DateTime<chrono::Utc>>,
2026-05-20 22:56:00 +02:00
}
// ════════════════════════════════════════════════════════════════════════════
// 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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
2026-05-20 22:56:00 +02:00
}
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,
expires_at: g.expires_at,
2026-05-20 22:56:00 +02:00
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// 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<chrono::Utc>,
},
/// 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<NotifyOutcomeDto>,
}
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<NotifyOutcomeDto>) -> 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<GrantDto>` shape; the frontend share modal is updated in
/// lockstep.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CreateGrantResponseDto {
pub grants: Vec<GrantDto>,
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<String>,
/// Sort dimension. Supported values: `"granted_at"` (default),
/// `"granted_by"` (for swimlane grouping).
pub sort_by: Option<String>,
/// Comma-separated resource types to include, e.g. `file,folder`.
/// Omit to return all known types.
pub resource_types: Option<String>,
/// 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<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),
}
/// 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<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,
/// 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<SharedWithMeItemDto>;
// ════════════════════════════════════════════════════════════════════════════
// 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<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
/// 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<chrono::Utc>,
/// Full resource details. Shape is determined by `resource_type`.
pub resource: ResourceContentDto,
/// One entry per (subject, permissions) pair.
pub grants: Vec<OutgoingResourceGrantDto>,
}
/// Response for `GET /api/grants/outgoing/resources`.
pub type MySharesDto = CursorListResponse<OutgoingResourceItemDto>;